laravel-graphql maintained by hmennen90
laravel-graphql
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.
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.