hmennen90 / laravel-graphql
A hand-written GraphQL engine with first-class Laravel integration (code-first + SDL).
Requires
- php: ^8.4
- illuminate/contracts: ^11.0 || ^12.0 || ^13.0
- illuminate/database: ^11.0 || ^12.0 || ^13.0
- illuminate/pagination: ^11.0 || ^12.0 || ^13.0
- illuminate/support: ^11.0 || ^12.0 || ^13.0
Requires (Dev)
- larastan/larastan: ^3.0
- laravel/scout: ^10.0
- orchestra/testbench: ^9.0 || ^10.0 || ^11.0
- phpstan/phpstan: ^2.0
- phpunit/phpunit: ^10.5 || ^11.0 || ^12.0
- rector/rector: ^2.0
README
A hand-written GraphQL engine with first-class Laravel integration — no
dependency on webonyx/graphql-php. Define your schema code-first (PHP),
schema-first (SDL), attribute-driven, or mix all three; they compile to
one internal schema.
Status: in active development. APIs may change before the first stable release.
Key differentiator — single source of truth
Unlike SDL-first stacks, you do not implement a type twice. There is no SDL type mirroring your Eloquent model plus a separate type/transformer class, and no directive DSL to keep in sync. Declare a type once; resolvers are plain PHP callables that read your models directly.
Features
- Own lexer, parser, AST, type system, validator and executor (spec-driven).
- Hybrid schema: code-first, SDL, and PHP attributes — one internal schema.
- Built-in scalars (
Int,Float,String,Boolean,ID) + custom scalars. - Objects, interfaces, unions, enums, input objects, lists, non-null.
- Comprehensive validation, introspection (GraphiQL/Apollo tooling),
@oneOf,@specifiedBy. - Custom directives (runtime middleware and build-time SDL), SDL type extensions.
- Eloquent directive layer (
@all,@find,@paginate,@hasMany,@whereConditions,@orderBy,@create/@update/@delete,@search, …) — available as SDL directives and equivalent PHP attributes (#[All],#[Paginate]). - Argument sanitisers & validation:
@trim,@hash,@globalId,@rules,@validator. - DataLoader (N+1 batching), query depth/complexity limits.
- Laravel: HTTP endpoint, batching, middleware/auth, error masking, GraphiQL,
file uploads, Automatic Persisted Queries,
@cacheControlHTTP caching, Relay pagination, subscriptions (broadcasting + graphql-ws), Apollo Federation. - PHP 8.4, PHPStan level 10, tested with
orchestra/testbench+ a GraphQL spec conformance suite (language, validation rules, execution, introspection).
Requirements
- PHP
^8.4 - Laravel 11, 12 or 13 (for the Laravel integration; the engine itself is framework-agnostic)
Installation
composer require hmennen90/laravel-graphql
Publish the config (and optionally a starter schema):
php artisan vendor:publish --tag=graphql-config
Quick start (Laravel)
Create a schema provider:
<?php namespace App\GraphQL; use App\Models\User; use Hmennen90\GraphQL\Contracts\ProvidesSchema; use Hmennen90\GraphQL\Engine\Schema\Schema; use Hmennen90\GraphQL\Engine\Schema\SchemaConfig; use Hmennen90\GraphQL\Engine\Type\Definition\Argument; use Hmennen90\GraphQL\Engine\Type\Definition\FieldDefinition; use Hmennen90\GraphQL\Engine\Type\Definition\ObjectType; use Hmennen90\GraphQL\Engine\Type\Definition\Type; final class AppSchema implements ProvidesSchema { public function schema(): Schema { $user = new ObjectType('User', [ FieldDefinition::make('id', Type::nonNull(Type::id())), FieldDefinition::make('name', Type::string()), FieldDefinition::make('email', Type::string()), ]); $query = new ObjectType('Query', [ FieldDefinition::make( 'user', $user, args: [Argument::make('id', Type::nonNull(Type::id()))], resolve: fn ($root, array $args) => User::find($args['id']), ), ]); return new Schema(new SchemaConfig(query: $query)); } }
Register it in config/graphql.php:
'schema' => [ 'factory' => App\GraphQL\AppSchema::class, ],
Query the endpoint:
curl -X POST http://localhost/graphql \ -H 'Content-Type: application/json' \ -d '{"query":"{ user(id: \"1\") { id name } }"}'
{ "data": { "user": { "id": "1", "name": "Ada" } } }
Defining a schema
Code-first
use Hmennen90\GraphQL\Engine\Type\Definition\{Argument, EnumType, EnumValueDefinition, FieldDefinition, InterfaceType, ObjectType, Type, UnionType}; $status = new EnumType('Status', [ new EnumValueDefinition('ACTIVE'), new EnumValueDefinition('ARCHIVED', deprecationReason: 'Use ACTIVE'), ]); $user = new ObjectType('User', [ FieldDefinition::make('id', Type::nonNull(Type::id())), FieldDefinition::make('name', Type::string()), FieldDefinition::make('status', Type::nonNull($status)), // Lists & non-null wrappers: FieldDefinition::make('tags', Type::listOf(Type::nonNull(Type::string()))), ]);
Recursive/cyclic types are fine — pass a closure for lazily-resolved fields:
$node = null; $node = new ObjectType('Node', fn (): array => [ FieldDefinition::make('id', Type::nonNull(Type::id())), FieldDefinition::make('parent', $node), ]);
Schema-first (SDL)
use Hmennen90\GraphQL\Engine\Building\SchemaFirst\SchemaBuilder; $sdl = <<<'GRAPHQL' type Query { user(id: ID!): User } type User { id: ID! name: String } GRAPHQL; $schema = SchemaBuilder::fromSdl($sdl, resolvers: [ 'Query' => [ 'user' => fn ($root, array $args) => User::find($args['id']), ], ]);
Fields without a resolver fall back to reading array keys, object properties or getters.
Attribute-driven
use Hmennen90\GraphQL\Engine\Building\CodeFirst\Attributes\GraphQLField; use Hmennen90\GraphQL\Engine\Building\CodeFirst\Attributes\GraphQLType; use Hmennen90\GraphQL\Engine\Building\CodeFirst\AttributeSchemaBuilder; use Hmennen90\GraphQL\Engine\Schema\Schema; use Hmennen90\GraphQL\Engine\Schema\SchemaConfig; #[GraphQLType(name: 'Query')] final class QueryType { #[GraphQLField(type: 'String!')] public function hello(): string { return 'world'; } #[GraphQLField(type: '[User!]!')] public function users(): array { return User::all()->all(); } } $types = (new AttributeSchemaBuilder())->build([QueryType::class]); $schema = new Schema(new SchemaConfig(query: $types['Query']));
SDL type extensions
type Query { hello: String! } extend type Query { world: String! }
Eloquent directives (CRUD without resolvers)
Build a full CRUD API over Eloquent declaratively — the model stays the single source of truth, and directives derive queries, columns and relations from it.
type Query { users: [User!]! @all user(id: ID!): User @find posts: [Post!]! @paginate @whereConditions(columns: ["title"]) @orderBy(columns: ["id"]) } type Mutation { createUser(name: String!, email: String!): User @create updateUser(id: ID!, name: String): User @update deleteUser(id: ID!): User @delete } type User { id: ID! name: String posts: [Post!]! @hasMany postsCount: Int @count(relation: "posts") }
Everything is also available as PHP attributes that dispatch to the exact same implementations — pick SDL or code-first per taste, with no duplicated logic:
use Hmennen90\GraphQL\Attributes\{All, Paginate, Guard}; #[GraphQLType(name: 'Query')] final class QueryType { #[GraphQLField(type: '[User!]!')] #[All(model: User::class)] public function users(): array { return []; } #[GraphQLField(type: '[Post!]!')] #[Paginate(type: 'CONNECTION')] #[Guard] public function posts(): array { return []; } }
Reading (@all/@find/@first/@paginate), relations
(@hasMany/@hasOne/@belongsTo/@belongsToMany/@morph*/@count), filtering & sorting
(@whereConditions/@orderBy), mutations incl. nested
(@create/@update/@delete/@upsert), auth/utility (@guard/@inject/@field/@rename)
and Laravel Scout (@search) are all supported. See
Eloquent directives for the full reference.
Apollo Federation
Expose any schema as a federated subgraph:
use Hmennen90\GraphQL\Federation\Federation; $subgraph = Federation::subgraph($schema, [ 'User' => [ 'model' => \App\Models\User::class, 'resolve' => fn (array $ref) => \App\Models\User::find($ref['id']), ], ]);
This adds _service { sdl }, _entities(representations:) and the _Any/_Service/
_Entity types, wiring one reference resolver per entity type. See
Apollo Federation.
Generating types from your Laravel app
The strongest form of "single source of truth": derive GraphQL types from the artifacts you already maintain — Eloquent models, FormRequest rules and JSON responses — instead of re-declaring their shape.
use Hmennen90\GraphQL\Generation\ModelTypeGenerator; use Hmennen90\GraphQL\Generation\ValidationInputGenerator; use Hmennen90\GraphQL\Generation\ResponseTypeGenerator; // 1. Object type from an Eloquent model (primary key, fillable, casts, timestamps) $userType = (new ModelTypeGenerator())->fromModel(\App\Models\User::class); // -> type User { id: ID! name: String active: Boolean meta: JSON created_at: String ... } // 2. Input type from a FormRequest's validation rules ("required" -> non-null) $createUserInput = (new ValidationInputGenerator()) ->fromRequest(\App\Http\Requests\StoreUserRequest::class, 'CreateUserInput'); // from ['name' => 'required|string', 'age' => 'integer'] -> input CreateUserInput { name: String! age: Int } // ...or straight from a rules array: $filterInput = (new ValidationInputGenerator())->fromRules([ 'term' => 'required|string', 'limit' => 'integer', ], 'FilterInput'); // 3. Object type inferred from a JSON resource / response shape $sample = (new \App\Http\Resources\UserResource($user))->toArray(request()); $userResourceType = (new ResponseTypeGenerator())->fromArray($sample, 'UserResource');
Compose the generated types into a schema like any hand-built type:
use Hmennen90\GraphQL\Engine\Schema\Schema; use Hmennen90\GraphQL\Engine\Schema\SchemaConfig; use Hmennen90\GraphQL\Engine\Type\Definition\{Argument, FieldDefinition, ObjectType, Type}; $query = new ObjectType('Query', [ FieldDefinition::make('user', $userType, args: [Argument::make('id', Type::nonNull(Type::id()))], resolve: fn ($root, array $args) => \App\Models\User::find($args['id'])), ]); $mutation = new ObjectType('Mutation', [ FieldDefinition::make('createUser', $userType, args: [Argument::make('input', Type::nonNull($createUserInput))], resolve: fn ($root, array $args) => \App\Models\User::create($args['input'])), ]); $schema = new Schema(new SchemaConfig(query: $query, mutation: $mutation, types: [$userResourceType]));
Mapping notes: model casts and rule tokens map to the built-in scalars;
array/jsoncasts andarrayrules use a bundledJSONscalar. Nested resource arrays become nested object types. Generators produce a starting point you can refine — add relations, hide fields, or wrap the returned types as needed.
Executing standalone (without Laravel)
The engine has no framework dependency:
use Hmennen90\GraphQL\Engine\Executor\Executor; use Hmennen90\GraphQL\Engine\Language\Parser; $result = Executor::execute($schema, Parser::parse('{ user(id: "1") { id name } }')); $result->toArray(); // ['data' => ['user' => ['id' => '1', 'name' => 'Ada']]]
Or validate first:
use Hmennen90\GraphQL\Engine\Validation\DocumentValidator; $errors = DocumentValidator::validate($schema, Parser::parse($query), [ 'maxDepth' => 10, 'maxComplexity' => 100, ]);
Laravel integration
Configuration
config/graphql.php controls the endpoint, GraphiQL, schema source, batching,
error handling, security limits, persisted queries, cache-control and subscriptions.
Artisan commands
php artisan graphql:print [--write[=path]] # export the schema as SDL php artisan graphql:validate # validate the schema (CI guard) php artisan graphql:lint # report unsupported directives (migration aid) php artisan graphql:cache # cache the parsed SDL for faster boots php artisan graphql:clear # drop schema cache + APQ entries php artisan graphql:subscriptions:serve # graphql-ws WebSocket server (Swoole) php artisan make:graphql-type UserType # code-first type php artisan make:graphql-directive FooDirective # build-time directive php artisan make:graphql-scalar DateType # custom scalar php artisan make:graphql-query FetchUser # single-field query resolver php artisan make:graphql-mutation CreateUser # single-field mutation resolver
The GraphQL facade
use Hmennen90\GraphQL\Facades\GraphQL; $result = GraphQL::execute('{ user(id: "1") { name } }'); $schema = GraphQL::schema();
Authorization
Inside a resolver, use the request Context:
use Hmennen90\GraphQL\Execution\Context; FieldDefinition::make('secret', Type::string(), resolve: function ($root, array $args, $context) { if ($context instanceof Context) { $context->authorize('view-secret'); // throws AuthorizationError on deny } return 'classified'; });
Or declaratively in SDL with the @can directive:
use Hmennen90\GraphQL\Directives\CanDirective; $schema = SchemaBuilder::fromSdl(<<<'GRAPHQL' directive @can(ability: String!) on FIELD_DEFINITION type Query { secret: String @can(ability: "view-secret") } GRAPHQL, resolvers: [/* ... */], schemaDirectives: ['can' => new CanDirective()]);
Argument validation
Use Laravel's validator inside a resolver; a thrown ValidationException surfaces
under errors[].extensions.validation:
FieldDefinition::make('register', $user, args: [ Argument::make('email', Type::nonNull(Type::string())), ], resolve: function ($root, array $args) { validator($args, ['email' => 'required|email'])->validate(); return User::create($args); });
Or declaratively in SDL with the built-in directives — @rules validates a single
argument, @validator binds a dedicated validator class, and the sanitisers run
before the resolver:
type Mutation { register( email: String @rules(apply: ["required", "email"]) name: String @trim password: String @hash ): User @create updatePost(id: ID @globalId, title: String): Post @update @validator(class: "App\\GraphQL\\Validators\\UpdatePostValidator") }
@rules(apply: [...])— validate one argument with Laravel rules.@validator(class:)— validate all arguments via a class exposingrules().@trim— strip surrounding whitespace;@hash— bcrypt a value;@globalId— decode a Relay global id down to its raw key.
See Validation & argument sanitisers for the full reference.
Error masking
With graphql.debug = false, internal exception messages are masked to
"Internal server error". Client-safe exceptions (authorization, authentication,
validation) pass through and are categorised under extensions.category.
File uploads
Add the Upload scalar and send a
GraphQL multipart request:
use Hmennen90\GraphQL\Support\UploadType; $upload = UploadType::make(); FieldDefinition::make('import', Type::string(), args: [ Argument::make('file', Type::nonNull($upload)), ], resolve: fn ($root, array $args) => $args['file']->getClientOriginalName());
Uploaded files arrive as Illuminate\Http\UploadedFile instances.
Automatic Persisted Queries (APQ)
// config/graphql.php 'persisted_queries' => ['enabled' => true],
Clients send extensions.persistedQuery.sha256Hash; the first request registers
the query, later requests may send the hash alone.
HTTP caching (@cacheControl)
// config/graphql.php 'cache_control' => ['enabled' => true],
directive @cacheControl(maxAge: Int, scope: String) on FIELD_DEFINITION type Query { articles: [Article!]! @cacheControl(maxAge: 60, scope: "PUBLIC") }
The endpoint emits a Cache-Control header from the minimum maxAge of the
selected fields. Combined with APQ (GET + hash), this enables CDN/HTTP caching.
Subscriptions
Enable and broadcast events:
// config/graphql.php 'subscriptions' => ['enabled' => true],
use Hmennen90\GraphQL\Subscriptions\SubscriptionManager; app(SubscriptionManager::class)->broadcast('postAdded', $post);
Clients subscribe via Laravel Echo (broadcasting) or a graphql-ws client. To run
the bundled graphql-ws server (requires the Swoole extension):
php artisan graphql:subscriptions:serve --port=9501
Performance
DataLoader (N+1 batching)
use Hmennen90\GraphQL\Engine\Executor\DataLoader; $companies = new DataLoader(function (array $ids) { $byId = Company::findMany($ids)->keyBy('id'); // must return one value per key, in the same order as $ids return array_map(fn ($id) => $byId->get($id), $ids); }); FieldDefinition::make('company', $companyType, resolve: fn (array $user) => $companies->load($user['company_id']));
All companies requested during one query are fetched in a single batch call.
Query limits
// config/graphql.php 'security' => [ 'max_depth' => 15, 'max_complexity' => 200, ],
Benchmarks
A dependency-free harness measures each phase (parse/build/validate/execute), list throughput and DataLoader batching:
composer bench # php benchmarks/run.php
Indicative results on an Apple Silicon laptop, PHP 8.4 (no JIT) — median over many iterations. Numbers are machine-specific; the shape (per-phase cost, scaling) is what matters:
| Scenario | Median | Throughput |
|---|---|---|
| parse: small query | ~6 µs | ~165k/s |
| parse: nested query | ~25 µs | ~40k/s |
| build: schema from SDL | ~100 µs | ~10k/s |
| validate: nested query | ~7 µs | ~142k/s |
| execute: flat field | ~2 µs | ~470k/s |
| execute: list of 100 | ~0.64 ms | ~1,560/s |
| execute: list of 1000 | ~6.4 ms | ~157/s |
| execute: 500 nested + DataLoader | ~7.8 ms | ~130/s |
| full: parse+validate+execute (100) | ~0.68 ms | ~1,470/s |
Parse, validate and small executions run in microseconds; list execution scales
linearly (~3.8 µs/object) after the executor completes synchronous fields inline. In an
engine-to-engine comparison this beats webonyx/graphql-php (Lighthouse's engine)
across every scenario, and end-to-end (Laravel + Eloquent) it resolves ~1.5× faster
than Lighthouse. See Benchmarks.
Custom directives
Runtime (query) directive — wrap field resolution:
use Closure; use Hmennen90\GraphQL\Engine\Executor\{DirectiveMiddleware, ResolveInfo}; use Hmennen90\GraphQL\Engine\Language\AST\DirectiveNode; final class UpperDirective implements DirectiveMiddleware { public function handle(DirectiveNode $node, ResolveInfo $info, Closure $resolve): mixed { $value = $resolve(); return is_string($value) ? strtoupper($value) : $value; } } // register on the schema: new SchemaConfig(query: $query, directiveMiddleware: ['upper' => new UpperDirective()]);
Build-time SDL directives implement SchemaDirective and are passed to
SchemaBuilder::fromSdl(..., schemaDirectives: [...]) (see @can/@cacheControl).
Relay pagination
use Hmennen90\GraphQL\Support\Relay\Relay; $connection = Relay::connectionType($userType); FieldDefinition::make('users', $connection, args: [ Argument::make('first', Type::int()), Argument::make('after', Type::string()), ], resolve: fn ($root, array $args) => Relay::connectionFromArray(User::all()->all(), $args));
Introspection & GraphiQL
Full introspection is supported. With graphql.graphiql.enabled = true, an
in-browser IDE is served at /graphiql.
Testing
composer test # PHPUnit (Unit + Feature via orchestra/testbench) composer analyse # PHPStan level 10
Contributing
Contributions are welcome — see CONTRIBUTING.md. Commits follow Conventional Commits; releases are cut with semantic-release. Please also read the Code of Conduct.
Security
Please report security issues privately — see SECURITY.md.
Changelog
See CHANGELOG.md (generated by semantic-release).
Comparison
A full, per-package feature comparison (vs. Lighthouse, rebing/graphql-laravel, webonyx/graphql-php) is in the documentation.
Migrating from Lighthouse
Not a drop-in replacement, but a realistic migration: the CRUD, relation, filter/sort
(incl. single-field @eq/@like/@scope/@limit…), soft-delete and auth directive
names and semantics were kept compatible, and convention-resolved query/mutation
classes carry over (graphql.namespaces). Run php artisan graphql:lint to get a
precise list of anything unsupported (e.g. @builder, @with, subscriptions), and use
the MakesGraphQLRequests test trait so existing feature tests keep working. See the
full
migration guide.
License
MIT — see LICENSE.