rebing/graphql-laravel
Code-first GraphQL integration for Laravel: define schemas, queries, and mutations in PHP classes instead of .graphql files. Supports multiple schemas, resolver middleware, privacy controls, and dataloaders to avoid n+1 queries.
1. Install and publish.
composer require rebing/graphql-laravel
php artisan vendor:publish --provider="Rebing\GraphQL\GraphQLServiceProvider"
composer require mll-lab/laravel-graphiql --dev # optional IDE at /graphiql
2. Flip introspection on for local work. It is disabled by default, so GraphiQL and every IDE/codegen tool will come back with an empty schema until you set GRAPHQL_DISABLE_INTROSPECTION=false in .env.
3. Look at exactly one file first: config/graphql.php. The schemas array is the whole mental model — each schema lists its query, mutation and types classes. Everything else (routes, middleware, security, tracing) hangs off that.
4. Scaffold instead of typing boilerplate. There are 12 generators; run php artisan list make:graphql to see all of them.
php artisan make:graphql:type BookType
php artisan make:graphql:query BooksQuery
5. First working endpoint — a Type, a Query, and a config entry:
// config/graphql.php → schemas.default
'query' => [App\GraphQL\Queries\BooksQuery::class],
'types' => [App\GraphQL\Types\BookType::class],
curl -X POST -H "Content-Type: application/json" \
-d '{"query":"{ books(title: \"1984\") { id title author } }"}' \
http://localhost:8000/graphql
6. Write your first test before your second resolver. The package ships test helpers on top of Orchestra Testbench:
$response = $this->httpGraphql('{ books { id title } }');
$response->assertJsonPath('data.books.0.title', 'The Great Gatsby');
That loop — scaffold, register, test — is the day-to-day cadence. The "What's next" chain in the README (Dataloaders → Eloquent queries → mutations → validation → authorization) is a sensible reading order for week one.
Schema definition: config vs. class. Small apps list classes in config/graphql.php. Once a schema exceeds ~10 entries, move to a class implementing ConfigConvertible and reference it by name in config — it keeps query/mutation/types/middleware co-located and makes per-schema middleware obvious in code review.
Reference types through the facade, never new.
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
public function type(): Type
{
return Type::nonNull(Type::listOf(Type::nonNull(GraphQL::type('Book'))));
}
The registry also parses modifier strings — GraphQL::type('[Book!]!') is equivalent to the above and reads better in dense field maps.
Resolver resolution has two routes. Either implement resolve($root, array $args) on the query/mutation, or omit it and let the Type auto-discover resolve{StudlyFieldName}Field() methods on the Type class. The latter is the pattern for relationship-ish fields:
class AuthorType extends GraphQLType
{
protected $attributes = ['name' => 'Author'];
public function fields(): array
{
return [
'id' => ['type' => Type::nonNull(Type::int())],
'posts' => ['type' => Type::listOf(GraphQL::type('Post'))],
];
}
public function resolvePostsField($author, array $args): array
{
return $author->posts()->get()->all();
}
}
Validation belongs in rules(), not in the resolver. Per-argument 'rules' => ['required', 'email'] works for simple cases; override rules() on the query/mutation for anything cross-field. Validation failures are wrapped in the package's ValidationError, so the response shape already matches what Laravel API clients expect — don't hand-roll throw new ValidationException.
Authorization belongs in authorize(). Returning false produces an AuthorizationError in the GraphQL error list rather than a 403 that kills the whole request — which is what you want, since sibling fields should still resolve. Use Privacy classes when the rule is field-level ("only the owner sees email") rather than operation-level.
Mutations: input type + rules + return type. The idiom that scales is one InputType per mutation, rules() on the mutation keyed to the input shape, and returning the mutated entity's Type so clients can request exactly what changed in one round trip.
Middleware is three layers, pick deliberately:
| Layer | Base class | Use for |
|---|---|---|
| HTTP | Laravel middleware, per schema | CORS, throttling, auth guards |
| Execution | AbstractExecutionMiddleware |
APQ, unused-variable checks, context injection, global timing |
| Resolver | Middleware ($middleware prop or resolver_middleware_append) |
Per-field auth, caching, audit logging |
Resolver middleware is where most business cross-cutting lives — it sees $root, $args, $context and ResolveInfo, so it can key a cache on the field name plus arguments.
Data loading: reach for dataloaders early.
// Loader batched per request; resolves N authors in one query
$loader = GraphQL::loader(...); // register in the type, load by id in the field resolver
Dataloaders use webonyx's deferred resolution and batch by tick — ideal for any N+1-prone relationship. The Eloquent-specific select()/with() optimization now lives in the separate rebing/graphql-laravel-select-fields package; only pull that in when you've measured a real cost.
Multiple schemas for API versioning and audience separation. A public schema and an admin schema with different HTTP middleware (different auth guard) is cheap to set up and far cleaner than if (user()->isAdmin()) inside resolvers. Each schema gets its own queries/mutations/types and its own middleware stack.
File uploads are handled by the UploadType scalar with a multipart request — the README has both Vue and vanilla JS examples.
Batching is supported for clients that send an array of operations; keep the batch limit configured so one client can't starve the pool.
php artisan config:clear was never run. Schema classes registered by FQCN are fine, but the config array is cached.new BookType() in a field map creates a second instance, and webonyx will reject duplicate named types or throw confusing "type already exists" errors. Always go through GraphQL::type().nonNull failures bubble all the way up. A nonNull field that resolves to null doesn't just null that field — it nulls the nearest nullable ancestor, and with a nonNull top-level list you lose the entire data key. When debugging "whole response is null with one error", look at the path in the error.src/Error/* (ValidationError, AuthorizationError, APQ errors) are client-safe and surface in errors[]. src/Exception/* (SchemaNotFound, TypeNotFound) are developer/config mistakes and are not client-safe. Throwing a raw RuntimeException from a resolver is the fastest way to leak internals or get an opaque 500 — throw a package Error or override error formatting.$model->relation. The default field resolver is fine, but a type field that touches $book->author->name lazily is one query per row. Audit with DB::listen or the package's SQL-count assertion helpers before shipping a list query.resolve() supports container injection. ResolveInfo and any container-resolvable class can be type-hinted — useful for pulling per-request services into resolvers without app() calls. Third-party packages can add their own injectors via Field::registerParameterInjector().GraphQL facade is Macroable; a pagination or timestamps macro applied to a Field map beats copy-pasting argument blocks — and it keeps a single point of change when arguments evolve.@deprecated warning from introspection instead of a sudden breaking change.schemaName to httpGraphql(). Forgetting it silently hits default and produces a "field not found" error that looks like a schema bug.open-telemetry/api) gives per-field resolver spans; for a slow query, enabling a tracing driver answers "which resolver" in one request instead of five dd() cycles.How can I help you explore Laravel packages today?