MonkeysLegion-GraphQL
Code-first GraphQL server for the MonkeysLegion framework — PHP 8.4 attributes, PSR-15, DataLoader, subscriptions, and security out of the box.
Requirements
- PHP 8.4+
webonyx/graphql-php^15.30
Installation
composer require monkeyscloud/monkeyslegion-graphql
The GraphQLProvider is auto-registered via composer.json extra.
Quick Start
1. Define a Type
use MonkeysLegion\GraphQL\Attribute\{Type, Field};#[Type(description: 'A user')]
final class UserType
{
#[Field]
public function id(User $root): int
{
return $root->id;
}
#[Field]
public function name(User $root): string
{
return $root->name;
}
#[Field(description: 'Email address')]
public function email(User $root): string
{
return $root->email;
}
}
2. Define a Query
use MonkeysLegion\GraphQL\Attribute\{Query, Arg};
use MonkeysLegion\GraphQL\Context\GraphQLContext;#[Query(name: 'user', description: 'Get user by ID')]
final class GetUserQuery
{
public function __construct(private UserRepository $users) {}
public function __invoke(
mixed $root,
#[Arg(description: 'User ID')] int $id,
GraphQLContext $context,
): ?User {
return $this->users->find($id);
}
}
3. Define a Mutation
use MonkeysLegion\GraphQL\Attribute\{Mutation, Arg};#[Mutation(name: 'createUser', description: 'Create a new user')]
final class CreateUserMutation
{
public function __construct(private UserRepository $users) {}
public function __invoke(
mixed $root,
#[Arg] string $name,
#[Arg] string $email,
): User {
return $this->users->create($name, $email);
}
}
4. Configure
# config/graphql.mlc
graphql:
endpoint: /graphql
scan:
directories:
- app/GraphQL
security:
max_depth: 10
max_complexity: 200
Features
Attributes
| Attribute | Target | Purpose |
|---|---|---|
#[Type] |
Class | GraphQL object type |
#[Field] |
Method/Property | Object type field |
#[Query] |
Class | Root query field |
#[Mutation] |
Class | Root mutation field |
#[Subscription] |
Class | Subscription field |
#[Arg] |
Parameter | Argument metadata |
#[InputType] |
Class | Input object type |
#[Enum] |
Backed enum | Enum type |
#[InterfaceType] |
Class/Interface | Interface type |
#[UnionType] |
Class | Union type |
#[Middleware] |
Class/Method | Per-field middleware |
Custom Scalars
DateTime— ISO 8601 serializationJSON— Arbitrary JSON passthroughEmail— Email format validationURL— URL format validationUpload— Multipart file upload
Security
graphql:
security:
max_depth: 10 # Query depth limiting
max_complexity: 200 # Field cost analysis
introspection: false # Disable introspection in production
persisted_queries: true # APQ with SHA256
rate_limit:
enabled: true
max_requests: 100
window_seconds: 60
DataLoader (N+1 Prevention)
use MonkeysLegion\GraphQL\Loader\DataLoader;final class UserLoader extends DataLoader
{
public function __construct(private UserRepository $users) {}
protected function batchLoad(array $keys): array
{
$users = $this->users->findByIds($keys);
return array_map(
fn(int $id) => $users[$id] ?? null,
$keys,
);
}
}
Relay Pagination
use MonkeysLegion\GraphQL\Type\ConnectionType;// Automatically creates UserConnection, UserEdge, PageInfo types
$connectionType = ConnectionType::create('User', $userType);
Subscriptions
use MonkeysLegion\GraphQL\Attribute\Subscription;#[Subscription(name: 'messageAdded', description: 'New message')]
final class MessageAddedSubscription
{
public function __invoke(mixed $root): Message
{
return $root;
}
}
Supports graphql-ws protocol with in-memory and Redis PubSub backends.
File Uploads
Follows the GraphQL multipart request spec:
#[Mutation(name: 'uploadFile')]
final class UploadFileMutation
{
public function __invoke(
mixed $root,
#[Arg] UploadedFileInterface $file,
): string {
$file->moveTo('/uploads/' . $file->getClientFilename());
return $file->getClientFilename();
}
}
CLI Commands
| Command | Description |
|---|---|
php ml graphql:schema:dump |
Dump schema as SDL |
php ml graphql:schema:validate |
Validate schema |
php ml graphql:cache:warm |
Warm schema cache |
php ml graphql:cache:clear |
Clear schema cache |
php ml graphql:introspect |
Dump introspection JSON |
Entity Integration
When monkeyslegion-entity is installed, you can auto-map your entities to GraphQL types without writing boilerplate type classes.
Entity Example
use MonkeysLegion\Entity\Attribute\Entity;
use MonkeysLegion\Entity\Attribute\Id;
use MonkeysLegion\Entity\Attribute\Column;#[Entity(table: 'products')]
class Product
{
#[Id]
public int $id;
#[Column(type: 'varchar', length: 255)]
public string $name;
#[Column(type: 'text')]
public string $description;
#[Column(type: 'decimal')]
public float $price;
#[Column(type: 'boolean')]
public bool $active;
#[Column(type: 'datetime')]
public \DateTimeImmutable $createdAt;
}
Auto-Map Entities to GraphQL Types
use MonkeysLegion\GraphQL\Scanner\EntityTypeMapper;$mapper = new EntityTypeMapper();
// Maps all typed properties → GraphQL fields automatically:
// int → Int! float → Float!
// string → String! bool → Boolean!
// DateTime* → DateTime! (custom scalar)
// ?string → String (nullable)
$typeConfig = $mapper->map(Product::class);
// Returns: ['name' => 'Product', 'fields' => ['id' => ..., 'name' => ..., ...]]
// Map multiple entities at once
$types = $mapper->mapAll([Product::class, Category::class, Order::class]);
Auto-Generated CRUD Resolvers
Use EntityResolver to expose entities without manual resolver classes:
use MonkeysLegion\GraphQL\Resolver\EntityResolver;
use MonkeysLegion\GraphQL\Type\ConnectionType;// Single entity by ID
// query { product(id: 42) { name price } }
$findProduct = EntityResolver::findById(
Product::class,
ProductRepository::class, // optional — defaults to Product::class . 'Repository'
);
// List all entities
// query { products { name price active } }
$listProducts = EntityResolver::findAll(Product::class);
// Relay-style pagination with cursors
// query { products(first: 10, after: "Y3Vyc29yOjk=") {
// edges { node { name } cursor }
// pageInfo { hasNextPage endCursor }
// totalCount
// }}
$paginatedProducts = EntityResolver::connection(Product::class);
Full Example: Entity-Backed Schema
use MonkeysLegion\GraphQL\Attribute\{Type, Field, Query, Arg};
use MonkeysLegion\GraphQL\Context\GraphQLContext;// 1. Define the GraphQL type wrapping the entity
#[Type(description: 'A product in the catalog')]
final class ProductType
{
#[Field]
public function id(Product $root): int { return $root->id; }
#[Field]
public function name(Product $root): string { return $root->name; }
#[Field]
public function price(Product $root): float { return $root->price; }
#[Field(description: 'Active in store?')]
public function active(Product $root): bool { return $root->active; }
#[Field(description: 'ISO 8601')]
public function createdAt(Product $root): string {
return $root->createdAt->format('c');
}
}
// 2. Query resolver using the repository from DI
#[Query(name: 'product', description: 'Find product by ID')]
final class GetProductQuery
{
public function __construct(private ProductRepository $products) {}
public function __invoke(
mixed $root,
#[Arg(description: 'Product ID')] int $id,
GraphQLContext $context,
): ?Product {
return $this->products->find($id);
}
}
// 3. List with filtering
#[Query(name: 'products', description: 'List products')]
final class ListProductsQuery
{
public function __construct(private ProductRepository $products) {}
public function __invoke(
mixed $root,
#[Arg(nullable: true)] ?bool $active,
#[Arg(nullable: true, defaultValue: 20)] int $limit,
): array {
if ($active !== null) {
return $this->products->findByActive($active, $limit);
}
return $this->products->findAll($limit);
}
}
Route Registration
GraphQLProvider automatically registers routes with monkeyslegion-router when the application boots.
Default Routes
| Method | Path | Handler | Description |
|---|---|---|---|
POST |
/graphql |
GraphQLMiddleware |
Queries & mutations |
GET |
/graphql |
GraphQLMiddleware |
Simple GET queries |
GET |
/graphiql |
GraphiQLMiddleware |
Interactive IDE (dev) |
Configuration
# config/graphql.mlc
graphql:
endpoint: /graphql # Change the endpoint path
graphiql_enabled: true # Disable GraphiQL in production
graphiql_endpoint: /graphiql # Custom GraphiQL path
debug: false # Enable for detailed error traces
scan_dirs:
- app/GraphQL # Where to find Type/Query/Mutation classes
scan_namespace: App\GraphQL # PSR-4 namespace for scanned classes
security:
max_depth: 10
max_complexity: 200
introspection: true # Disable in production
persisted_queries: false
rate_limit:
max_requests: 100
window_seconds: 60
cache:
enabled: false
ttl: 3600 # Schema cache TTL in seconds
subscriptions:
enabled: false
driver: memory # 'memory' or 'redis'
host: 0.0.0.0
port: 6001
redis_dsn: redis://127.0.0.1:6379
How Route Registration Works
The GraphQLProvider::register() method is called automatically during application bootstrap (via the monkeyslegion extra in composer.json). Here's what happens:
// This happens automatically — no manual setup needed.
// The provider:
// 1. Reads config/graphql.mlc
// 2. Registers all GraphQL services in the DI container
// 3. Registers routes with MonkeysLegion\Router\Router// Routes are registered as closures that delegate to PSR-15 middleware:
$router->post('/graphql', $graphqlHandler, 'graphql');
$router->get('/graphql', $graphqlHandler, 'graphql.get');
$router->get('/graphiql', $graphiqlHandler, 'graphiql'); // if enabled
Custom Route Middleware
Stack your own middleware (auth, CORS, rate-limiting) alongside GraphQL:
use MonkeysLegion\GraphQL\Attribute\Middleware;// Per-resolver middleware
#[Middleware('App\Middleware\AuthMiddleware')]
#[Middleware('App\Middleware\RateLimitMiddleware')]
#[Query(name: 'adminUsers')]
final class AdminUsersQuery
{
public function __invoke(mixed $root, GraphQLContext $context): array
{
// Only reached if auth + rate-limit pass
return $context->container->get(UserRepository::class)->findAdmins();
}
}
Testing the Endpoint
# Simple query
curl -X POST http://localhost:8080/graphql \
-H 'Content-Type: application/json' \
-d '{"query": "{ product(id: 1) { name price } }"}'Mutation
curl -X POST http://localhost:8080/graphql
-H 'Content-Type: application/json'
-d '{"query": "mutation { createProduct(name: "Widget", price: 9.99) { id name } }"}'
GET request (simple queries only)
curl 'http://localhost:8080/graphql?query=\{products\{name\}\}'
Open GraphiQL IDE in browser
Facade
use MonkeysLegion\GraphQL\GraphQL;// Execute a query programmatically
$result = GraphQL::execute('{ user(id: 1) { name } }');
// Publish a subscription event
GraphQL::publish('messageAdded', $message);
// Get the built schema
$schema = GraphQL::schema();
License
MIT — see LICENSE for details.
Related Packages
First-class Stripe integration package for the MonkeysLegion PHP framework, providing PSR-compliant HTTP clients and service container integration.
API Resources & Transformers — PHP 8.4 property hooks, JSON:API, attribute-driven field control, pagination, and OpenAPI schema generation