rebing/graphql-laravel
Code-first GraphQL integration for Laravel built on webonyx/graphql-php. Define schemas, types, queries and mutations in PHP with support for multiple schemas, middleware, resolver middleware, privacy rules, and n+1 avoidance via dataloaders or SelectFields.
This package provides a code-first integration of GraphQL for Laravel. It is based on the PHP port of GraphQL reference implementation. You define your schema entirely in PHP classes (types, queries, mutations) rather than in .graphql schema files. You can find more information about GraphQL in the Introduction to GraphQL or you can read the GraphQL specifications.
select() and eager-loaded with() callsNote: GraphQL subscriptions are not supported by this package. If you need real-time push functionality, consider a dedicated solution like Lighthouse (which has subscription support) or implement subscriptions separately via Laravel broadcasting / WebSockets.
| Dependency | Version |
|---|---|
| PHP | ^8.2 |
| Laravel | 12.x - 13.x |
| webonyx/graphql-php | ^15.22.1 |
Optional dependencies:
| Package | Purpose |
|---|---|
open-telemetry/api ^1.0 |
Required for the OpenTelemetry tracing driver |
mll-lab/laravel-graphiql |
Interactive in-browser GraphiQL IDE |
Require the package via Composer:
composer require rebing/graphql-laravel
Publish the configuration file via Laravel artisan:
php artisan vendor:publish --provider="Rebing\GraphQL\GraphQLServiceProvider"
Review the configuration file:
config/graphql.php
Get a working GraphQL endpoint in under 5 minutes -- no database required.
Use the artisan generator to scaffold a type:
php artisan make:graphql:type BookType
Edit the generated app/GraphQL/Types/BookType.php:
declare(strict_types = 1);
namespace App\GraphQL\Types;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Type as GraphQLType;
class BookType extends GraphQLType
{
protected $attributes = [
'name' => 'Book',
'description' => 'A book',
];
public function fields(): array
{
return [
'id' => [
'type' => Type::nonNull(Type::int()),
'description' => 'The id of the book',
],
'title' => [
'type' => Type::nonNull(Type::string()),
'description' => 'The title of the book',
],
'author' => [
'type' => Type::string(),
'description' => 'The name of the author',
],
];
}
}
php artisan make:graphql:query BooksQuery
Edit app/GraphQL/Queries/BooksQuery.php:
declare(strict_types = 1);
namespace App\GraphQL\Queries;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
use Rebing\GraphQL\Support\Query;
class BooksQuery extends Query
{
protected $attributes = [
'name' => 'books',
];
public function type(): Type
{
return Type::nonNull(Type::listOf(Type::nonNull(GraphQL::type('Book'))));
}
public function args(): array
{
return [
'title' => [
'type' => Type::string(),
'description' => 'Filter by title',
],
];
}
public function resolve($root, array $args): array
{
$books = [
['id' => 1, 'title' => 'The Great Gatsby', 'author' => 'F. Scott Fitzgerald'],
['id' => 2, 'title' => '1984', 'author' => 'George Orwell'],
['id' => 3, 'title' => 'To Kill a Mockingbird', 'author' => 'Harper Lee'],
];
if (isset($args['title'])) {
return array_values(array_filter($books, fn ($book) => str_contains($book['title'], $args['title'])));
}
return $books;
}
}
Add the type and query to the default schema in config/graphql.php:
'schemas' => [
'default' => [
'query' => [
App\GraphQL\Queries\BooksQuery::class,
],
'mutation' => [],
'types' => [
App\GraphQL\Types\BookType::class,
],
],
],
Start the dev server and send a query:
php artisan serve
curl -X POST -H "Content-Type: application/json" \
-d '{"query": "{ books { id title author } }"}' \
http://localhost:8000/graphql
Expected response:
{
"data": {
"books": [
{"id": 1, "title": "The Great Gatsby", "author": "F. Scott Fitzgerald"},
{"id": 2, "title": "1984", "author": "George Orwell"},
{"id": 3, "title": "To Kill a Mockingbird", "author": "Harper Lee"}
]
}
}
Try filtering with an argument:
curl -X POST -H "Content-Type: application/json" \
-d '{"query": "{ books(title: \"1984\") { id title } }"}' \
http://localhost:8000/graphql
Tip: For an interactive experience, install GraphiQL (
composer require mll-lab/laravel-graphiql --dev) and visit/graphiqlin your browser.
Note: Introspection is disabled by default. To enable it during development (required for GraphiQL and IDE tooling), set
GRAPHQL_DISABLE_INTROSPECTION=falsein your.envfile.
You now have a working GraphQL API. From here you can:
rebing/graphql-laravel-select-fields packagephp artisan list make:graphql to see all 12 available scaffolding commandsBefore diving head first into code, it's good to familiarize yourself with the concepts surrounding GraphQL. If you've already experience with GraphQL, feel free to skip this part.
Typically, all queries/mutations/types are defined using the $attributes
property and the args() / fields() methods as well as the resolve() method.
args/fields again return a configuration array for each field they supported. Those fields usually support these shapes
type (required): a GraphQL specifier for the type supported hereOptional keys are:
description: made available when introspecting the GraphQL schemaresolve: override the default field resolverdeprecationReason: document why something is deprecatednonNullIt's quite common, and actually good practice, to see the gracious use of
Type::nonNull() on any kind of input and/or output fields.
The more specific the intent of your type system, the better for the consumer.
Some examples
null values, declare the type like this:Type::nonNull(Type::listOf(Type::nonNull(Type::string())))There exists a lot of tooling in the GraphQL ecosystem, which benefits the more specific your type system is.
The act of loading/retrieving your data is called "resolving" in GraphQL. GraphQL itself does not define the "how" and leaves it up to the implementor.
You can use any kind of data source you like (Eloquent, static data,
ElasticSearch results, caching, etc.) in your resolvers, but you need to be
mindful of the execution model to avoid repetitive fetches. This library
supports two strategies for optimized data loading. Dataloaders are the
recommended starting point -- they work with any data source and follow the
standard GraphQL community pattern for n+1 prevention. The optional
rebing/graphql-laravel-select-fields
package is available as an Eloquent-specific alternative that offers column-level
precision.
Dataloaders take advantage of the "deferred" execution model built into webonyx/graphql-php. Instead of analyzing the query upfront, each field resolver collects the keys it needs and defers the actual fetch. Once all non-deferred fields are resolved, the deferred callbacks fire, batching all collected keys into a single query.
This is the recommended approach for most applications -- it works with any data source (Eloquent, APIs, caches, etc.) and does not require any special type configuration. See Dataloaders for usage and examples.
Note: SelectFields has been extracted to a separate optional package:
rebing/graphql-laravel-select-fields. Install it viacomposer require rebing/graphql-laravel-select-fieldsto use theSelectFieldsclass and its field configuration keys (selectable,is_relation,always,query). See the package's README for full documentation.
| SelectFields | Dataloaders | |
|---|---|---|
| Data source | Eloquent only | Any (Eloquent, APIs, caches, etc.) |
| N+1 strategy | Upfront eager loading via query AST analysis | Deferred batching at resolve time |
| Column precision | Selects only requested columns | Typically all columns (customizable per loader) |
| Setup | composer require rebing/graphql-laravel-select-fields |
Create a loader class, register in the container |
| Best for | Eloquent-heavy apps needing column-level optimization | Most applications; especially mixed data sources and cross-resolver batching |
The two approaches are independent and can coexist in the same application --
use SelectFields for some resolvers and dataloaders for others.
The following middleware concepts are supported:
Briefly said, a middleware usually is a class:
handle methodAny Laravel compatible HTTP middleware
can be provided on a global level for all GraphQL endpoints via the config
graphql.route.middleware or on a per-schema basis via
graphql.schemas.<yourschema>.middleware. The per-schema middleware overrides
the global one.
The processing of a GraphQL request, henceforth called "execution", flows through a set of middlewares.
They can be set on global level via graphql.execution_middleware or per-schema
via graphql.schemas.<yourschema>.execution_middleware.
By default, the recommended set of middlewares is provided on the global level.
Note: the execution of the GraphQL request itself is also implemented via a
middleware, which is usually expected to be called last (and does not call
further middlewares). In case you're interested in the details, please see
\Rebing\GraphQL\GraphQL::appendGraphqlExecutionMiddleware
After the HTTP middleware and the execution middleware is applied, the
"resolver middleware" is executed for the query/mutation being targeted
before the actual resolve() method is called.
See Resolver middleware for more details.
Schemas are required for defining GraphQL endpoints. You can define multiple schemas and assign different HTTP middleware and execution middleware to them, in addition to the global middleware. For example:
'default_schema' => 'default',
'schemas' => [
'default' => [
'query' => [
ExampleQuery::class,
],
'mutation' => [
ExampleMutation::class,
],
'types' => [
],
],
'user' => [
'query' => [
App\GraphQL\Queries\ProfileQuery::class
],
'mutation' => [
],
'types' => [
],
'middleware' => ['auth:api'],
// Which HTTP methods to support; must be given in UPPERCASE!
// Default is POST only; enable GET explicitly if needed
'method' => ['GET', 'POST'],
'execution_middleware' => [
\Rebing\GraphQL\Support\ExecutionMiddleware\UnusedVariablesMiddleware::class,
],
// Route attributes applied to the generated HTTP route for this schema
// Example: expose this schema on a dedicated subdomain
'route_attributes' => [
'domain' => 'api.example.com',
],
// Per-schema route group attributes. The `guard` entry is consumed by
// the built-in `AddAuthUserContextValueMiddleware` to populate the
// GraphQL context value (`$ctx`) from the named guard. Defaults to
// the application's default guard when unset.
'group_attributes' => [
'guard' => 'api',
],
// Override the default controller for this schema.
// Supports string ('Class@method') and array ([Class::class, 'method']) formats.
// The controller method receives the same parameters as GraphQLController@query.
// 'controller' => App\Http\Controllers\MyGraphQLController::class . '@query',
],
],
Together with the configuration, in a way the schema defines also the route by
which it is accessible. Per the default configuration of prefix = graphql, the
default schema is accessible via /graphql.
You can customize the HTTP route generated for a specific schema using the route_attributes key.
This is useful for setting parameters supported by Laravel routes, e.g. a custom domain.
The attributes are merged into the route's action array, so standard Laravel route attributes
like domain, prefix, as (route name), and where (parameter constraints) are all supported.
'schemas' => [
'with_custom_domain' => [
'query' => [
App\GraphQL\Queries\UsersQuery::class,
],
'middleware' => ['auth:api'],
'route_attributes' => [
'domain' => 'api.example.com',
],
],
]
You may alternatively define the configuration of a schema in a class that implements ConfigConvertible.
In your config, you can reference the name of the class, rather than an array.
'schemas' => [
'default' => DefaultSchema::class
]
declare(strict_types = 1);
namespace App\GraphQL\Schemas;
use Rebing\GraphQL\Support\Contracts\ConfigConvertible;
class DefaultSchema implements ConfigConvertible
{
public function toConfig(): array
{
return [
'query' => [
ExampleQuery::class,
],
'mutation' => [
ExampleMutation::class,
],
'types' => [
],
];
}
}
You can use the php artisan make:graphql:schemaConfig command to create a new schema configuration class automatically.
First you usually create a type you want to return from the query. The Eloquent 'model' is only required if specifying relations.
Note: If you use the
rebing/graphql-laravel-select-fieldspackage, you can set'selectable' => falseon computed/virtual fields that don't correspond to a database column (e.g. accessors, custom resolvers).
declare(strict_types = 1);
namespace App\GraphQL\Types;
use App\Models\User;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
use Rebing\GraphQL\Support\Type as GraphQLType;
class UserType extends GraphQLType
{
protected $attributes = [
'name' => 'User',
'description' => 'A user',
// Note: only necessary if you use the SelectFields package
'model' => User::class,
];
public function fields(): array
{
return [
'id' => [
'type' => Type::nonNull(Type::string()),
'description' => 'The id of the user',
// Use 'alias', if the database column is different from the type name.
// This is supported for discrete values as well as relations.
// - you can also use `DB::raw()` to solve more complex issues
// - or a callback returning the value (string or `DB::raw()` result)
'alias' => 'user_id',
],
'email' => [
'type' => Type::string(),
'description' => 'The email of user',
'resolve' => function($root, array $args) {
// If you want to resolve the field yourself,
// it can be done here
return strtolower($root->email);
}
],
// Uses the 'getIsMeAttribute' function on our custom User model
'isMe' => [
'type' => Type::boolean(),
'description' => 'True, if the queried user is the current user',
'selectable' => false, // Does not try to query this from the database
],
// Reference another registered GraphQL type for relations
'profile' => [
'type' => GraphQL::type('UserProfile'),
'description' => 'The user profile',
],
];
}
// You can also resolve a field by declaring a method in the class
// with the following format resolve[FIELD_NAME]Field()
protected function resolveEmailField($root, array $args)
{
return strtolower($root->email);
}
}
The best practice is to start with your schema in config/graphql.php and add types directly to your schema (e.g. default):
'schemas' => [
'default' => [
// ...
'types' => [
App\GraphQL\Types\UserType::class,
],
Alternatively you can:
add the type on the "global" level, e.g. directly in the root config:
'types' => [
App\GraphQL\Types\UserType::class,
],
Adding them on the global level allows to share them between different schemas but be aware this might make it harder to understand which types/fields are used where.
or add the type with the GraphQL Facade, in a service provider for example.
GraphQL::addType(\App\GraphQL\Types\UserType::class);
or register multiple types at once with addTypes:
GraphQL::addTypes([
\App\GraphQL\Types\UserType::class,
'CustomName' => \App\GraphQL\Types\PostType::class,
]);
Both indexed entries (class name auto-resolved) and associative entries (explicit name => class) are supported.
Then you need to define a query that returns this type (or a list). You can also specify arguments that you can use in the resolve method.
declare(strict_types = 1);
namespace App\GraphQL\Queries;
use Closure;
use App\Models\User;
use Rebing\GraphQL\Support\Facades\GraphQL;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Query;
class UsersQuery extends Query
{
protected $attributes = [
'name' => 'users',
];
public function type(): Type
{
return Type::nonNull(Type::listOf(Type::nonNull(GraphQL::type('User'))));
}
public function args(): array
{
return [
'id' => [
'type' => Type::string(),
],
'email' => [
'type' => Type::string(),
]
];
}
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo)
{
if (isset($args['id'])) {
return User::where('id' , $args['id'])->get();
}
if (isset($args['email'])) {
return User::where('email', $args['email'])->get();
}
return User::all();
}
}
Add the query to the config/graphql.php configuration file
'schemas' => [
'default' => [
'query' => [
App\GraphQL\Queries\UsersQuery::class
],
// ...
]
]
declare(strict_types = 1);
namespace App\GraphQL\Queries;
use App\Models\User;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
use Rebing\GraphQL\Support\Query;
class UserQuery extends Query
{
protected $attributes = [
'name' => 'user',
];
public function type(): Type
{
return Type::nonNull(GraphQL::type('User'));
}
public function args(): array
{
return [
'id' => [
'type' => Type::nonNull(Type::int()),
],
];
}
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo)
{
return User::findOrFail($args['id']);
}
}
And that's it. You should be able to query GraphQL with a POST request to the url /graphql (or anything you choose in your config). Try a POST request with the following query input
Note: The
resolve()method supports dependency injection for parameters beyond the first three ($root,$args,$context). You can typehintResolveInfo $infoto receive the GraphQL resolve info. Any other class typehint will be resolved from Laravel's service container. External packages can register custom parameter injectors viaField::registerParameterInjector(). See Resolve method for full details.
query FetchUsers {
users {
id
email
}
}
For example, using curl:
curl -X POST -H "Content-Type: application/json" \
-d '{"query": "query FetchUsers { users { id email } }"}' \
http://localhost:8000/graphql
A mutation is like any other query. It accepts arguments and returns an object of a certain type. Mutations are meant to be used for operations modifying (mutating) the state on the server (which queries are not supposed to perform).
This is conventional abstraction, technically you can do anything you want in a query resolve, including mutating state.
For example, a mutation to update the password of a user. First you need to define the Mutation:
declare(strict_types = 1);
namespace App\GraphQL\Mutations;
use Closure;
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Rebing\GraphQL\Support\Facades\GraphQL;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Definition\ResolveInfo;
use Rebing\GraphQL\Support\Mutation;
class UpdateUserPasswordMutation extends Mutation
{
protected $attributes = [
'name' => 'updateUserPassword'
];
public function type(): Type
{
return Type::nonNull(GraphQL::type('User'));
}
public function args(): array
{
return [
'id' => [
'type' => Type::nonNull(Type::string()),
],
'password' => [
'type' => Type::nonNull(Type::string()),
]
];
}
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo)
{
$user = User::find($args['id']);
if(!$user) {
return null;
}
$user->password = Hash::make($args['password']);
$user->save();
return $user;
}
}
As you can see in the resolve() method, you use the arguments to update your model and return it.
You should then add the mutation to the config/graphql.php configuration file:
'schemas' => [
'default' => [
'mutation' => [
App\GraphQL\Mutations\UpdateUserPasswordMutation::class,
],
// ...
]
]
You can then use the following query on your endpoint to do the mutation:
mutation users {
updateUserPassword(id: "1", password: "newpassword") {
id
email
}
}
For example, using curl:
curl -X POST -H "Content-Type: application/json" \
-d '{"query": "mutation users { updateUserPassword(id: \"1\", password: \"newpassword\") { id email } }"}' \
http://localhost:8000/graphql
You can attach Laravel validation rules directly to each argument via the
'rules' key. This is often simpler than overriding the rules() method for
straightforward validations:
declare(strict_types = 1);
namespace App\GraphQL\Mutations;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
use Rebing\GraphQL\Support\Mutation;
class LoginMutation extends Mutation
{
protected $attributes = [
'name' => 'login',
'description' => 'Log in by email and password',
];
public function type(): Type
{
return GraphQL::type('User');
}
public function args(): array
{
return [
'email' => [
'type' => Type::nonNull(Type::string()),
'rules' => ['required', 'email'],
],
'password' => [
'type' => Type::nonNull(Type::string()),
'rules' => ['required', 'string', 'min:8'],
],
'remember_me' => [
'type' => Type::boolean(),
'rules' => ['boolean'],
],
];
}
public function resolve($root, array $args)
{
// Authenticate and return the user...
}
}
See Validation for more advanced rule definitions including
the rules() method, callable rules, and nested input type rules.
This library uses https://github.com/laragraph/utils which is compliant with the spec at https://github.com/jaydenseric/graphql-multipart-request-spec .
You have to add the \Rebing\GraphQL\Support\UploadType first to your config/graphql schema types definition (either global or in your schema):
'types' => [
\Rebing\GraphQL\Support\UploadType::class,
],
It is important that you send the request as multipart/form-data:
WARNING: when you are uploading files, Laravel will use FormRequest - it means that middlewares which are changing request, will not have any effect.
declare(strict_types = 1);
namespace App\GraphQL\Mutations;
use Closure;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
use Rebing\GraphQL\Support\Mutation;
class UserProfilePhotoMutation extends Mutation
{
protected $attributes = [
'name' => 'userProfilePhoto',
];
public function type(): Type
{
return GraphQL::type('User');
}
public function args(): array
{
return [
'profilePicture' => [
'type' => GraphQL::type('Upload'),
'rules' => ['required', 'image', 'max:1500'],
],
];
}
public function resolve($root, array $args, $context, ResolveInfo $resolveInfo)
{
$file = $args['profilePicture'];
// Do something with file here...
}
}
Note: You can test your file upload implementation using Altair as explained here.
<template>
<div>
<input type="file" ref="fileInput" @change="handleFileChange" />
<button :disabled="!file" @click="upload">Upload</button>
</div>
</template>
<script setup>
import { ref } from 'vue';
const file = ref(null);
const fileInput = ref(null);
function handleFileChange() {
file.value = fileInput.value.files[0];
}
async function upload() {
if (!file.value) return;
const formData = new FormData();
formData.set('operations', JSON.stringify({
query: `mutation uploadSingleFile($file: Upload!) {
upload_single_file(attachment: $file)
}`,
variables: { attachment: null },
}));
formData.set('map', JSON.stringify({ '0': ['variables.attachment'] }));
formData.append('0', file.value);
const response = await fetch('/graphql', {
method: 'POST',
body: formData,
});
const result = await response.json();
if (!result.errors) {
file.value = null;
}
}
</script>
<input type="file" id="fileUpload">
const fileInput = document.getElementById('fileUpload');
const file = fileInput.files[0];
const formData = new FormData();
formData.set('operations', JSON.stringify({
query: `mutation uploadSingleFile($file: Upload!) {
upload_single_file(attachment: $file)
}`,
variables: { attachment: null },
}));
formData.set('map', JSON.stringify({ '0': ['variables.attachment'] }));
formData.append('0', file);
const response = await fetch('/graphql', {
method: 'POST',
body: formData,
});
const result = await response.json();
Laravel's validation is supported on queries, mutations, input types and field arguments.
Note: The support is "sugar on top" and is provided as a convenience. It may have limitations in certain cases, in which case regular Laravel validation can be used in your respective
resolve()methods, just like in regular Laravel code.
Adding validation rules is supported in the following ways:
'rules' is supported
function args()function fields()'args' declared for a field\Rebing\GraphQL\Support\Field::rules on any query/mutation/input typeValidator in your resolve() methodUsing the configuration key 'rules' is very convenient, as it is declared in
the same location as the GraphQL type itself. However, you may hit certain
restrictions with this approach (like multi-field validation using *), in
which case you can override the rules() method.
class UpdateUserEmailMutation extends Mutation
{
//...
public function args(): array
{
return [
'id' => [
'type' => Type::string(),
'rules' => ['required']
],
'email' => [
'type' => Type::string(),
'rules' => ['required', 'email']
]
];
}
//...
}
rules() methoddeclare(strict_types = 1);
namespace App\GraphQL\Mutations;
use Closure;
use App\Models\User;
use Rebing\GraphQL\Support\Facades\GraphQL;
use GraphQL\Type\Definition\ResolveInfo;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Mutation;
class UpdateUserEmailMutation extends Mutation
{
protected $attributes = [
'name' => 'updateUserEmail'
];
public function type(): Type
{
return GraphQL::type('User');
}
public function args(): array
{
return [
'id' => [
'type' => Type::string(),
],
'email' => [
'type' => Type::string(),
]
];
}
protected function rules(array $args = []): array
{
return [
'id' => ['required'],
'email' => ['required', 'email'],
'password' => $args['id'] !== 1337 ? ['required'] : [],
];
}
public function resolve($root, array $args)
{
$user = User::find($args['id']);
if (!$user) {
return null;
}
$user->email = $args['email'];
$user->save();
return $user;
}
}
Calling validate() in the example below will throw Laravel's ValidationException
which is handed by the default error_formatter by this library:
protected function resolve($root, array $args) {
\Illuminate\Support\Facades\Validator::make($args, [
'data.*.password' => 'string|nullable|same:data.*.password_confirmation',
])->validate();
}
The format of the 'rules' configuration key, or the rules returned by the
rules() method, follows the same convention that Laravel supports, e.g.:
'rules' => 'required|string''rules' => ['required', 'string']'rules' => function (…) { … }For the args() method or the 'args' definition for a field, the field names
are directly used for the validation. However, for input types, which can be
nested and occur multiple times, the field names are mapped as e.g.
data.0.fieldname. This is important to understand when returning rules from
the rules() method.
Exceptions are used to communicate back in the GraphQL response that validation
errors occurred. When using the built-in support, the exception
\Rebing\GraphQL\Error\ValidationError is thrown. In your custom code or when
directly using the Laravel Validator, Laravel's built-in
\Illuminate\Validation\ValidationException is supported too. In both cases,
the GraphQL response is transformed to the error format shown below.
To support returning validation errors in a GraphQL error response, the
'extensions' are used, as there's no proper equivalent.
On the client side, you can check if message for a given error matches
'validation', you can expect the extensions.validation key which maps each
field to their respective errors:
{
"data": {
"updateUserEmail": null
},
"errors": [
{
"message": "validation",
"extensions": {
"validation": {
"email": [
"The email is invalid."
]
}
},
"locations": [
{
"line": 1,
"column": 20
}
]
}
]
}
You can customize the way this is handled by providing your own error_formatter
in the configuration, replacing the default one from this library.
The validation errors returned can be customised by overriding the
validationErrorMessages method. This method should return an array of custom
validation messages in the same way documented by Laravel's validation. For
example, to check an email argument doesn't conflict with any existing data,
you could perform the following:
Note: the keys should be in
field_name.validator_typeformat, so you can return specific errors per validation type.
public function validationErrorMessages(array $args = []): array
{
return [
'name.required' => 'Please enter your full name',
'name.string' => 'Your name must be a valid string',
'email.required' => 'Please enter your email address',
'email.email' => 'Please enter a valid email address',
'email.exists' => 'Sorry, this email address is already in use',
];
}
The validation attributes can be customised by overriding the
validationAttributes method. This method should return an array of custom
attributes in the same way documented by Laravel's validation.
public function validationAttributes(array $args = []): array
{
return [
'email' => 'email address',
];
}
When using Laravel validation rules that reference sibling fields (like
prohibits, required_without, required_if, etc.) within an InputType, the
library automatically transforms those references into fully-qualified
dot-notation paths that Laravel's Validator can resolve correctly.
For example, given an InputType:
class RecipientInput extends InputType
{
protected $attributes = ['name' => 'RecipientInput'];
public function fields(): array
{
return [
'createParams' => [
'type' => Type::string(),
'rules' => ['nullable', 'prohibits:mintParams'],
],
'mintParams' => [
'type' => Type::string(),
'rules' => ['nullable', 'prohibits:createParams'],
],
];
}
}
Used in a mutation as a list:
public function args(): array
{
return [
'recipients' => [
'type' => Type::nonNull(Type::listOf(Type::nonNull(GraphQL::type('RecipientInput')))),
],
];
}
The prohibits:mintParams rule on recipients.0.createParams is automatically
transformed to prohibits:recipients.0.mintParams so that Laravel's Validator
correctly resolves the sibling field reference.
This applies to all dependent rules including prohibits, required_with,
required_with_all, required_without, required_without_all, present_with,
present_with_all, missing_with, missing_with_all, exclude_with,
exclude_without, same, different, required_if, required_unless,
prohibited_if, prohibited_unless, exclude_if, exclude_unless,
accepted_if, declined_if, present_if, present_unless, missing_if,
missing_unless, required_if_accepted, required_if_declined,
prohibited_if_accepted, prohibited_if_declined, and comparison rules like
gt, gte, lt, lte, before, after, before_or_equal, after_or_equal
(when they reference a sibling field). For rules like required_if that take
both a field reference and a value (e.g. required_if:mode,advanced), only the
field reference parameter is transformed. See RulesPrefixer for the full list.
Disabling automatic prefixing: If you need to opt out of this behavior for
a specific query or mutation, override processCollectedRules():
class MyMutation extends Mutation
{
protected function processCollectedRules(array $rules): array
{
return $rules; // disable automatic cross-field rule prefixing
}
}
Certain type declarations of GraphQL may cancel our or render certain validations
unnecessary. A good example is using Type::nonNull() to ultimately declare
that an argument is required. In such a case a 'rules' => 'required'
configuration will likely never be triggered, because the GraphQL execution
engine already prevents this field from being accepted in the first place.
Or to be more clear: if a GraphQL type system violation occurs, then no Laravel validation will be even executed, as the code does not get so far.
The resolve method is used in both queries and mutations, and it's here that responses are created.
The first three parameters to the resolve method are hard-coded:
$root object this resolve method belongs to (can be null)array $args (can be an empty array)\Rebing\GraphQL\Support\ExecutionMiddleware\AddAuthUserContextValueMiddleware
for an example.Arguments here after will be attempted to be injected, similar to how controller methods works in Laravel.
You can typehint any class that you will need an instance of.
There are two hardcoded classes which depend on the local data for the query:
GraphQL\Type\Definition\ResolveInfo has information useful for field resolution process.Field::registerParameterInjector() (e.g. the SelectFields class from rebing/graphql-laravel-select-fields).Example:
declare(strict_types = 1);
namespace App\GraphQL\Queries;
use App\Models\User;
use Rebing\GraphQL\Support\Facades\GraphQL;
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Definition\ResolveInfo;
use Rebing\GraphQL\Support\Query;
use SomeClassNamespace\SomeClassThatDoLogging;
class UsersQuery extends Query
{
protected $attributes = [
'name' => 'users',
];
public function type(): Type
{
return Type::listOf(GraphQL::type('User'));
}
public function args(): array
{
return [
'id' => [
'type' => Type::string(),
]
];
}
public function resolve($root, array $args, $context, ResolveInfo $info, SomeClassThatDoLogging $logging)
{
$logging->log('fetched user');
$users = User::all();
return $users->get();
}
}
These are GraphQL specific resolver middlewares and are only conceptually related to Laravel's "HTTP middleware". The main difference:
To create a new middleware, use the make:graphql:middleware Artisan command
php artisan make:graphql:middleware ResolvePage
This command will place a new ResolvePage class within your app/GraphQL/Middleware directory.
In this middleware, we will set the Paginator current page to the argument we accept via our PaginationType:
declare(strict_types = 1);
namespace App\GraphQL\Middleware;
use Closure;
use GraphQL\Type\Definition\ResolveInfo;
use Illuminate\Pagination\Paginator;
use Rebing\GraphQL\Support\Middleware;
class ResolvePage extends Middleware
{
public function handle($root, array $args, $context, ResolveInfo $info, Closure $next)
{
Paginator::currentPageResolver(function () use ($args) {
return $args['pagination']['page'] ?? 1;
});
return $next($root, $args, $context, $info);
}
}
If you would like to assign middleware to specific queries/mutations,
list the middleware class in the $middleware property of your query class.
declare(strict_types = 1);
namespace App\GraphQL\Queries;
use App\GraphQL\Middleware;
use Rebing\GraphQL\Support\Query;
class UsersQuery extends Query
{
protected $middleware = [
Middleware\Logstash::class,
Middleware\ResolvePage::class,
];
}
If you want a middleware to run during every GraphQL query/mutation to your application,
list the middleware class in the $middleware property of your base query class.
declare(strict_types = 1);
namespace App\GraphQL\Queries;
use App\GraphQL\Middleware;
use Rebing\GraphQL\Support\Query as BaseQuery;
abstract class Query extends BaseQuery
{
protected $middleware = [
Middleware\Logstash::class,
Middleware\ResolvePage::class,
];
}
Alternatively, you can override getMiddleware to supply your own logic:
protected function getMiddleware(): array
{
return array_merge([...], $this->middleware);
}
If you want to register middleware globally, use the resolver_middleware_append key in config/graphql.php (defaults to null, treated as an empty array):
return [
...
'resolver_middleware_append' => [YourMiddleware::class],
];
You can also use the appendGlobalResolverMiddleware method in any ServiceProvider:
...
public function boot()
{
...
GraphQL::appendGlobalResolverMiddleware(YourMiddleware::class);
// Or with new instance
GraphQL::appendGlobalResolverMiddleware(new YourMiddleware(...));
}
If your middleware needs to wrap all other resolver middleware (including
per-field middleware), use prependGlobalResolverMiddleware instead:
GraphQL::prependGlobalResolverMiddleware(YourOutermostMiddleware::class);
The resulting pipeline order is: prepended global middleware, per-field middleware, appended global middleware. This is used internally by the tracing system but is available for any middleware that must run outermost.
Sometimes a middleware may need to do some work after the response has been sent to the browser. If you define a terminate method on your middleware and your web server is using FastCGI, the terminate method will automatically be called after the response is sent to the browser:
declare(strict_types = 1);
namespace App\GraphQL\Middleware;
use Countable;
use GraphQL\Language\Printer;
use GraphQL\Type\Definition\ResolveInfo;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\AbstractPaginator;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Rebing\GraphQL\Support\Middleware;
class Logstash extends Middleware
{
public function terminate($field, array $args, $context, ResolveInfo $info, $result): void
{
Log::channel('logstash')->info('', (
collect([
'query' => $info->fieldName,
'operation' => $info->operation->name->value ?? null,
'type' => $info->operation->operation,
'fields' => array_keys(Arr::dot($info->getFieldSelection($depth = PHP_INT_MAX))),
'schema' => Arr::first(Route::current()->parameters()) ?? Config::get('graphql.default_schema', 'default'),
'vars' => $this->formatVariableDefinitions($info->operation->variableDefinitions),
])
->when($result instanceof Countable, function ($metadata) use ($result) {
return $metadata->put('count', $result->count());
})
->when($result instanceof AbstractPaginator, function ($metadata) use ($result) {
return $metadata->put('per_page', $result->perPage());
})
->when($result instanceof LengthAwarePaginator, function ($metadata) use ($result) {
return $metadata->put('total', $result->total());
})
->merge($this->formatArguments($args))
->toArray()
));
}
private function formatArguments(array $args): array
{
return collect($args)
->mapWithKeys(function ($value, $key) {
return ["\${$key}" => $value];
})
->toArray();
}
private function formatVariableDefinitions(?iterable $variableDefinitions = []): array
{
return collect($variableDefinitions)
->map(function ($def) {
return Printer::doPrint($def);
})
->toArray();
}
}
The terminate method receives both the resolver arguments and the query result.
Once you have defined a terminable middleware, you should add it to the list of middleware in your queries and mutations.
For authorization similar to Laravel's Request (or middleware) functionality, we can override the authorize() function in a Query or Mutation.
Important: The
authorize()method must return exactlytrue(strict comparison) for the request to proceed. Returning other truthy values (e.g.1,"yes") will be treated as unauthorized.
Note: Authorization is checked before validation rules are evaluated. This prevents unauthenticated users from probing validation rules to discover API structure.
An example of Laravel's 'auth' middleware:
declare(strict_types = 1);
namespace App\GraphQL\Queries;
use Illuminate\Support\Facades\Auth;
use GraphQL\Type\Definition\ResolveInfo;
class UsersQuery extends Query
{
public function authorize($root, array $args, $ctx, ResolveInfo $resolveInfo = null): bool
{
// true, if logged in
return ! Auth::guest();
}
// ...
}
Or we can make use of arguments passed via the GraphQL query:
declare(strict_types = 1);
namespace App\GraphQL\Queries;
use Illuminate\Support\Facades\Auth;
use GraphQL\Type\Definition\ResolveInfo;
class UsersQuery extends Query
{
public function authorize($root, array $args, $ctx, ResolveInfo $resolveInfo = null): bool
{
if (isset($args['id'])) {
return Auth::id() == $args['id'];
}
return true;
}
// ...
}
You can also provide a custom error message when the authorization fails (defaults to Unauthorized):
declare(strict_types = 1);
namespace App\GraphQL\Queries;
use Illuminate\Support\Facades\Auth;
use GraphQL\Type\Definition\ResolveInfo;
class UsersQuery extends Query
{
public function authorize($root, array $args, $ctx, ResolveInfo $resolveInfo = null): bool
{
if (isset($args['id'])) {
return Auth::id() == $args['id'];
}
return true;
}
public function getAuthorizationMessage(): string
{
return 'You are not authorized to perform this action';
}
// ...
}
If you share the same authorization logic across multiple queries or mutations, extract it into a reusable trait:
declare(strict_types = 1);
namespace App\GraphQL\Concerns;
use Illuminate\Support\Facades\Auth;
trait RequiresAuthentication
{
public function authorize($root, array $args, $ctx): bool
{
return !Auth::guest();
}
}
Then use it on any query or mutation:
class UsersQuery extends Query
{
use \App\GraphQL\Concerns\RequiresAuthentication;
// ...
}
You can set custom privacy attributes for every Type's Field. If a field is not
allowed, null will be returned. Privacy is enforced at the field resolver
level, so it works universally - whether the type is a root query result or a
nested sub-type.
The privacy callback receives four arguments: the root value ($root - the
parent object being resolved), the field's own arguments ($args), the
query context ($ctx), and optionally the ResolveInfo ($info).
Using a closure:
use Illuminate\Support\Facades\Auth;
class UserType extends GraphQLType
{
// ...
public function fields(): array
{
return [
'id' => [
'type' => Type::nonNull(Type::string()),
'description' => 'The id of the user',
],
'email' => [
'type' => Type::string(),
'description' => 'The email of user',
'privacy' => function (mixed $root, array $args, $ctx): bool {
// Only the authenticated user can see their own email.
// $root is the User model being resolved.
// $ctx is the query context value (see notes below).
// By default, AddAuthUserContextValueMiddleware sets
// $ctx to the authenticated user model directly.
return $root->id === Auth::id();
},
],
];
}
// ...
}
Using a Privacy class:
You can also create a class that extends the abstract Privacy class:
use Illuminate\Support\Facades\Auth;
use GraphQL\Type\Definition\ResolveInfo;
use Rebing\GraphQL\Support\Privacy;
class MePrivacy extends Privacy
{
public function validate(mixed $root, array $fieldArgs, mixed $queryContext = null, ?ResolveInfo $resolveInfo = null): bool
{
return $root->id === Auth::id();
}
}
Then reference it by class name on the field:
use MePrivacy;
class UserType extends GraphQLType
{
// ...
public function fields(): array
{
return [
'id' => [
'type' => Type::nonNull(Type::string()),
'description' => 'The id of the user',
],
'email' => [
'type' => Type::string(),
'description' => 'The email of user',
'privacy' => MePrivacy::class,
],
];
}
// ...
}
Using field arguments in a privacy check:
If the field declares its own args, they are available in $args:
'ssn' => [
'type' => Type::string(),
'args' => [
'reason' => [
'type' => Type::nonNull(Type::string()),
],
],
'privacy' => function (mixed $root, array $args, $ctx): bool {
// Only allow access when a valid reason is provided.
return in_array($args['reason'] ?? '', ['legal', 'compliance']);
},
],
$root- the parent object. This is the result of the parent field's resolver. For fields on aUserTyperesolved from a query returning Eloquent models,$rootwill be theUsermodel instance. This allows per-row privacy decisions (e.g. only show a field if the current user "owns" the object).
$args- field arguments, not query arguments. The$argsparameter contains the arguments declared on the field itself (via theargskey). If the field declares no arguments,$argswill be an empty array. These are not the root query/mutation arguments.
$ctx- the query context value. This is the context value passed to the GraphQL execution. By default, the built-inAddAuthUserContextValueMiddlewareexecution middleware sets this directly to the authenticated user model (i.e.Auth::user()), ornullif no user is authenticated. The guard is resolved in this order:graphql.schemas.<name>.group_attributes.guard(per-schema), then the globalgraphql.route.group_attributes.guard, then the application's default guard. This keeps the GraphQL context consistent with the guard that authenticated the route — important in multi-guard apps where the default guard (typicallyweb) and the schema's actual guard (e.g.api) differ. You can customize the context via your own execution middleware.
Privacy vs Authorization.
authorize()on a Query or Mutation gates the entire operation - if it fails, the whole request is rejected with an error.privacyon a Type field gates individual fields and silently returnsnullwhen denied. Useauthorize()for access control on operations andprivacyfor field-level visibility within types.
Caution with non-null fields. When privacy denies access, the field resolver returns
null. If the field is typed asType::nonNull(...), thisnullviolates the GraphQL non-null contract and causes an error that propagates up to the nearest nullable parent. Always use nullable types for privacy-protected fields.
GraphQL offers you the possibility to use variables in your query so you don't need to "hardcode" value. This is done like that:
query FetchUserByID($id: String)
{
user(id: $id) {
id
email
}
}
When you query the GraphQL endpoint, you can pass a JSON encoded variables parameter.
For example, using curl:
curl -X POST -H "Content-Type: application/json" \
-d '{"query": "query FetchUserByID($id: Int) { user(id: $id) { id email } }", "variables": {"id": 123}}' \
http://localhost:8000/graphql
You can also define a field as a class if you want to reuse it in multiple types.
declare(strict_types = 1);
namespace App\GraphQL\Fields;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Field;
class PictureField extends Field
{
protected $attributes = [
'description' => 'A picture',
];
public function type(): Type
{
return Type::string();
}
public function args(): array
{
return [
'width' => [
'type' => Type::int(),
'description' => 'The width of the picture'
],
'height' => [
'type' => Type::int(),
'description' => 'The height of the picture'
]
];
}
protected function resolve($root, array $args)
{
$width = isset($args['width']) ? $args['width']:100;
$height = isset($args['height']) ? $args['height']:100;
return 'https://placehold.co/'.$width.'x'.$height;
}
}
You can then use it in your type declaration
declare(strict_types = 1);
namespace App\GraphQL\Types;
use App\GraphQL\Fields\PictureField;
use App\Models\User;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Type as GraphQLType;
class UserType extends GraphQLType
{
protected $attributes = [
'name' => 'User',
'description' => 'A user',
'model' => User::class,
];
public function fields(): array
{
return [
'id' => [
'type' => Type::nonNull(Type::string()),
'description' => 'The id of the user'
],
'email' => [
'type' => Type::string(),
'description' => 'The email of user'
],
//Instead of passing an array, you pass a class path to your custom field
'picture' => PictureField::class
];
}
}
Instead of using the class name, you can also supply an actual instance of the Field. This allows you to not only re-use the field, but will also open up the possibility to re-use the resolver.
Let's imagine we want a field type that can output dates formatted in all sorts of ways.
declare(strict_types = 1);
namespace App\GraphQL\Fields;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Field;
class FormattableDate extends Field
{
protected $attributes = [
'description' => 'A field that can output a date in all sorts of ways.',
];
public function __construct(array $settings = [])
{
$this->attributes = \array_merge($this->attributes, $settings);
}
public function type(): Type
{
return Type::string();
}
public function args(): array
{
return [
'format' => [
'type' => Type::string(),
'defaultValue' => 'Y-m-d H:i',
'description' => 'Defaults to Y-m-d H:i',
],
'relative' => [
'type' => Type::boolean(),
'defaultValue' => false,
],
];
}
protected function resolve($root, array $args): ?string
{
$date = $root->{$this->getProperty()};
if (!$date instanceof Carbon) {
return null;
}
if ($args['relative']) {
return $date->diffForHumans();
}
return $date->format($args['format']);
}
protected function getProperty(): string
{
return $this->attributes['alias'] ?? $this->attributes['name'];
}
}
You can use this field in your type as follows:
declare(strict_types = 1);
namespace App\GraphQL\Types;
use App\GraphQL\Fields\FormattableDate;
use App\Models\User;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Type as GraphQLType;
class UserType extends GraphQLType
{
protected $attributes = [
'name' => 'User',
'description' => 'A user',
'model' => User::class,
];
public function fields(): array
{
return [
'id' => [
'type' => Type::nonNull(Type::string()),
'description' => 'The id of the user'
],
'email' => [
'type' => Type::string(),
'description' => 'The email of user'
],
// You can simply supply an instance of the class
'dateOfBirth' => new FormattableDate,
// Because the constructor of `FormattableDate` accepts our the array of parameters,
// we can override them very easily.
// Imagine we want our field to be called `createdAt`, but our database column
// is called `created_at`:
'createdAt' => new FormattableDate([
'alias' => 'created_at',
])
];
}
}
Dataloaders are the standard GraphQL pattern for solving n+1 problems and are the recommended default data loading strategy. They use deferred resolution -- a mechanism built into webonyx/graphql-php, the GraphQL engine this library is built on. Dataloaders do not require Eloquent models or special type configuration, and they work with any data source.
The pattern has two phases:
GraphQL\Deferred instead of a value.A loader is a plain PHP class that accumulates keys and performs a single bulk query when triggered. Registering it as a scoped singleton ensures a fresh instance per request (safe for Laravel Octane and queue workers).
declare(strict_types = 1);
namespace App\GraphQL\Loaders;
use App\Models\User;
use GraphQL\Deferred;
class UserLoader
{
/** @var list<int> */
private array $pendingIds = [];
/** @var array<int,User> */
private array $loaded = [];
/**
* Register a key to be loaded and return a deferred resolver.
*/
public function load(int $id): Deferred
{
$this->pendingIds[] = $id;
return new Deferred(function () use ($id): ?User {
$this->loadPending();
return $this->loaded[$id] ?? null;
});
}
/**
* Bulk-fetch all pending keys in a single query.
*/
private function loadPending(): void
{
$ids = array_diff(array_unique($this->pendingIds), array_keys($this->loaded));
$this->pendingIds = [];
if ($ids === []) {
return;
}
$users = User::whereIn('id', $ids)->get()->keyBy('id');
foreach ($users as $id => $user) {
$this->loaded[$id] = $user;
}
}
}
Register it in a service provider:
// AppServiceProvider::register()
$this->app->scoped(\App\GraphQL\Loaders\UserLoader::class);
Resolve the loader from the container and call load() with the key. The
returned Deferred is handled transparently by the GraphQL execution engine --
no changes to your schema, middleware, or controller are required.
declare(strict_types = 1);
namespace App\GraphQL\Types;
use App\GraphQL\Loaders\UserLoader;
use App\Models\Post;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Facades\GraphQL;
use Rebing\GraphQL\Support\Type as GraphQLType;
class PostType extends GraphQLType
{
protected $attributes = [
'name' => 'Post',
'description' => 'A blog post',
];
public function fields(): array
{
return [
'id' => [
'type' => Type::nonNull(Type::int()),
],
'title' => [
'type' => Type::nonNull(Type::string()),
],
'author' => [
'type' => GraphQL::type('User'),
'description' => 'The post author, loaded via dataloader',
'resolve' => function (Post $post) {
return app(UserLoader::class)->load($post->author_id);
},
],
];
}
}
If a query requests 50 posts, the author resolver is called 50 times -- but
only one SELECT * FROM users WHERE id IN (...) query is executed, because all
50 IDs are collected during the "collect" phase and fetched together when the
first Deferred callback fires.
GraphQL\Deferred is a synchronous promise provided by webonyx/graphql-php. When
a resolver returns a Deferred, the executor sets the value aside and continues
resolving other fields. Once no more immediate fields remain, it drains the
deferred queue: each callback runs, and if any callback returns another
Deferred, that is queued too. This continues until all values are fully resolved.
Because this library's execution middleware calls GraphQL::executeQuery() (which
uses the built-in SyncPromiseAdapter internally), Deferred works out of the
box with no additional configuration.
For applications with many loaders, the
overblog/dataloader-php library
provides a higher-level DataLoader class with automatic request batching,
per-request memoization, and cache priming. It ships with a webonyx/graphql-php
sync promise adapter. For most Laravel applications the simple loader pattern
shown above is sufficient, but overblog/dataloader-php can reduce boilerplate
when you have dozens of entity types to batch-load.
Note: The
SelectFieldsclass has been moved to the separaterebing/graphql-laravel-select-fieldspackage. Install it withcomposer require rebing/graphql-laravel-select-fieldsto use Eloquent-optimized field selection and eager loading in your resolvers. See the package's README for full documentation.
When using JSON columns in your database, the field won't be defined as a "relationship",
but rather a simple column with nested data. If you use the
rebing/graphql-laravel-select-fields
package, set the is_relation attribute to false in your Type to prevent
SelectFields from treating it as an Eloquent relation:
class UserType extends GraphQLType
{
// ...
public function fields(): array
{
return [
// ...
// JSON column containing all posts made by this user
'posts' => [
'type' => Type::listOf(GraphQL::type('Post')),
'description' => 'A list of posts written by the user',
// Tells SelectFields this is not an Eloquent relation
// The value defaults to true
'is_relation' => false
]
];
}
// ...
}
Sometimes you would want to deprecate a field but still have to maintain backward compatibility
until clients completely stop using that field. You can deprecate a field using
directive. If you add deprecationReason
to field attributes it will become marked as deprecated in GraphQL documentation. You can validate schema on client
using Apollo GraphOS.
declare(strict_types = 1);
namespace App\GraphQL\Types;
use App\Models\User;
use GraphQL\Type\Definition\Type;
use Rebing\GraphQL\Support\Type as GraphQLType;
class UserType extends GraphQLType
{
protected $attributes = [
'name' => 'User',
'description' => 'A user',
'model' => User::class,
];
public function fields(): array
{
return [
'id' => [
'type' => Type::nonNull(Type::string()),
'description' => 'The id of the user',
],
'email' => [
'type' => Type::string(),
'description' => 'The email of user',
],
'address' => [
'type' => Type::string(),
'description' => 'The address of user',
'deprecationReason' => 'Deprecated due to address field split'
],
'address_line_1' => [
'type' => Type::string(),
'description' => 'The address line 1 of user',
],
'address_line_2' => [
'type' => Type::string(),
'description' => 'The address line 2 of user',
],
];
}
}
It's possible to override the default field resolver provided by the underlying
webonyx/graphql-php library using the config option defaultFieldResolver.
You can define any valid callable (static class method, closure, etc.) for it:
'defaultFieldResolver' => [Your\Klass::class, 'staticMethod'],
The parameters received are your regular "resolve" function signature.
If you would like to define some helpers that you can re-use in a variety of your
queries, mutations and types, you may use the macro method on the GraphQL facade.
For example, from a service provider's boot method:
declare(strict_types = 1);
namespace App\Providers;
use GraphQL\Type\Definition\Type;
use Illuminate\Support\ServiceProvider;
use Rebing\GraphQL\Support\Facades\GraphQL;
class AppServiceProvider extends ServiceProvider
{
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot()
{
GraphQL::macro('listOf', function (string $name): Type {
return Type::listOf(GraphQL::type($name));
});
}
}
The macro function accepts a name as its first argument, and a Closure as its second.
Automatic Persisted Queries (APQ) improve network performance by sending smaller requests, with zero build-time configuration.
APQ is disabled by default and can be enabled in the config via apq.enable=true or by setting the environment variable GRAPHQL_APQ_ENABLE=true.
A persisted query is an ID or hash that can be generated on the client sent to the server instead of the entire GraphQL query string.
This smaller signature reduces bandwidth utilization and speeds up client loading times.
Persisted queries pair especially with GET requests, enabling the browser cache and integration with a CDN.
Note that GET requests are disabled by default; to use APQ with GET, you must explicitly set 'method' => ['GET', 'POST'] in your schema configuration.
Behind the scenes, APQ uses Laravel's cache for storing / retrieving the queries. They are parsed by GraphQL before storing, so re-parsing them again is not necessary. Please see the various options there for which cache, prefix, TTL, etc. to use.
Note: it is advised to clear the cache after a deployment to accommodate for changes in your schema!
For more information see:
Note: the APQ protocol requires the hash sent by the client being compared with the computed hash on the server. In case a mutating middleware like
TrimStringsis active and the query sent contains leading/trailing whitespaces, these hashes can never match resulting in an error.In such case either disable the middleware or trim the query on the client before hashing.
Below a simple integration example with Vue 3 and Apollo Client, where createPersistedQueryLink
automatically manages the APQ flow.
// [example apollo.js]
import { ApolloClient, InMemoryCache, HttpLink, ApolloLink } from '@apollo/client/core';
import { createPersistedQueryLink } from '@apollo/client/link/persisted-queries';
import { sha256 } from 'crypto-hash';
const httpLink = new HttpLink({ uri: '/graphql' });
const persistedQueryLink = createPersistedQueryLink({ sha256 });
export const apolloClient = new ApolloClient({
link: ApolloLink.from([persistedQueryLink, httpLink]),
cache: new InMemoryCache(),
connectToDevTools: true,
});
<!-- [example TestComponent.vue] -->
<template>
<div>
<p>Test APQ</p>
<p v-if="loading">Loading...</p>
<p v-else>{{ result?.hello }}</p>
</div>
</template>
<script setup>
import { useQuery } from '@vue/apollo-composable';
import gql from 'graphql-tag';
const { result, loading } = useQuery(gql`
query {
hello
}
`);
</script>
GraphQL operations can be instrumented with timing data by configuring a tracing
driver. Tracing is disabled by default ('driver' => null).
The built-in OpenTelemetryTracingDriver emits spans via the OpenTelemetry API
following the GraphQL semantic conventions.
It requires the open-telemetry/api ^1.0 package.
Install the OpenTelemetry API package first:
composer require open-telemetry/api
Then configure the driver:
'tracing' => [
'driver' => \Rebing\GraphQL\Support\Tracing\OpenTelemetryTracingDriver::class,
'driver_options' => [
// Include the GraphQL document in spans (may contain sensitive data)
'include_document' => true,
],
],
Without an OTel SDK configured, all spans are automatically no-ops.
To actually collect and export spans, you need to install and configure the
OpenTelemetry PHP SDK along
with an exporter for
your backend. The
Getting Started guide
walks through a complete example. Once the SDK is configured, the driver
automatically picks up the global TracerProvider - no additional wiring is
needed in this package.
By default, only the top-level operation is traced. To instrument individual
field resolvers, enable field_tracing:
'tracing' => [
'driver' => \Rebing\GraphQL\Support\Tracing\OpenTelemetryTracingDriver::class,
'field_tracing' => true,
],
With OpenTelemetry this creates a child span for each resolved field.
When tracing is enabled (i.e. a driver is configured), the tracing execution
and resolver middlewares (TracingExecutionMiddleware and
TracingResolverMiddleware) are automatically registered - you do not need to
add them to the execution_middleware or resolver_middleware_append config
arrays manually.
Note: Field tracing produces high-cardinality data and is intended for development/debugging. Use it with caution in production.
You can implement the Rebing\GraphQL\Support\Tracing\TracingDriver interface
to create your own driver. The interface has four methods:
startOperation(schemaName, operationName, operationType, source) - called before executionendOperation(context, ExecutionResult) - called after execution; receives the opaque context from startOperation and may modify the resultstartFieldResolve(ResolveInfo) - called before each field resolve (when field tracing is enabled)endFieldResolve(context, ResolveInfo) - called after each field resolveRegister your driver class in the tracing.driver config key and it will be
resolved from the Laravel service container. If the driver constructor accepts
an array $driverOptions parameter, it will receive the merged driver_options
from the global and per-schema tracing config.
By default, the global tracing configuration applies to every schema. You can
override tracing on a per-schema basis by adding a tracing key inside the
schema's config array.
Disable tracing for a specific schema:
'schemas' => [
'internal' => [
'query' => [/* ... */],
'tracing' => false, // no tracing for this schema
],
],
Enable tracing only for a specific schema (no global driver):
// Global: tracing disabled
'tracing' => [
'driver' => null,
],
'schemas' => [
'default' => [
'query' => [/* ... */],
// No 'tracing' key - inherits global (disabled)
],
'monitored' => [
'query' => [/* ... */],
'tracing' => [
'driver' => \Rebing\GraphQL\Support\Tracing\OpenTelemetryTracingDriver::class,
'field_tracing' => true,
],
],
],
Override driver options per schema (deep-merged over global):
// Global: tracing enabled, document excluded
'tracing' => [
'driver' => \Rebing\GraphQL\Support\Tracing\OpenTelemetryTracingDriver::class,
'driver_options' => [
'include_document' => false,
],
],
'schemas' => [
'debug' => [
'query' => [/* ... */],
'tracing' => [
'field_tracing' => true,
'driver_options' => [
'include_document' => true, // override for this schema only
],
],
],
],
Per-schema tracing arrays are deep-merged over the global config: schema
values win for top-level keys, and driver_options is merged separately so you
can override individual options without repeating the full array.
GraphQL APIs have a different attack surface than REST APIs. A single endpoint accepts arbitrary queries, so without safeguards a client can craft deeply nested or highly complex queries that exhaust server resources.
Schema introspection lets clients discover your entire type system -- every query, mutation, field, and argument. This is essential for development tooling (GraphiQL, IDE plugins, codegen) but exposes your full API surface in production.
Introspection is disabled by default:
// config/graphql.php
'security' => [
'disable_introspection' => env('GRAPHQL_DISABLE_INTROSPECTION', true),
],
Set GRAPHQL_DISABLE_INTROSPECTION=false in your .env during development.
Deeply nested queries can cause excessive resolver calls and memory usage. The
query_max_depth option rejects queries that exceed the allowed nesting level:
'security' => [
'query_max_depth' => 13, // default
],
For example, with a depth limit of 3, the query { users { posts { comments { author { name } } } } } would be rejected because it nests 4 levels deep.
Tune this based on your schema's legitimate nesting requirements. Start strict and increase only if real queries require it.
Complex queries (many fields, large lists) can be expensive even when shallow.
The query_max_complexity option assigns a cost to each resolved field and
rejects queries that exceed the budget:
'security' => [
'query_max_complexity' => 500, // default
],
You can assign custom complexity to individual fields using the complexity
callback supported by webonyx/graphql-php:
'posts' => [
'type' => Type::listOf(GraphQL::type('Post')),
'complexity' => fn (int $childCost, array $args): int => $childCost * ($args['limit'] ?? 10),
],
See the webonyx/graphql-php security documentation for full details on how complexity is calculated.
When batching is enabled, clients can send multiple operations in a
single HTTP request. Without a cap, this can be used to amplify the impact of
expensive queries. Batching is disabled by default, and when enabled the
batching.max_batch_size option (default: 10) limits the number of operations
per request.
By default each schema only accepts POST ('method' => ['POST']). If you
opt a schema in to GET (e.g. for CDN-cacheable persisted queries), enable
the ReadOnlyOperationMiddleware execution middleware so that mutations and
subscriptions submitted via GET are rejected.
The middleware is shipped pre-listed (commented out) in the published config:
// config/graphql.php
'execution_middleware' => [
Rebing\GraphQL\Support\ExecutionMiddleware\ValidateOperationParamsMiddleware::class,
Rebing\GraphQL\Support\ExecutionMiddleware\AutomaticPersistedQueriesMiddleware::class,
Rebing\GraphQL\Support\ExecutionMiddleware\ReadOnlyOperationMiddleware::class,
Rebing\GraphQL\Support\ExecutionMiddleware\AddAuthUserContextValueMiddleware::class,
],
Without this middleware, an operation submitted via GET is executed
regardless of its type, which leaks mutation arguments into URLs, server
access logs and CDN caches and bypasses defenses that assume GET is safe.
This mirrors the protection that webonyx/graphql-php's
Server\Helper::executeOperation() applies; we enforce it explicitly because
the library calls GraphQL::executeQuery() directly.
The rejection produces the standard GraphQL error
GET supports only query operation with HTTP 200. The middleware is opt-in
so consumers running with the default POST-only configuration are unaffected.
Ordering matters when APQ is enabled. When used together with
AutomaticPersistedQueriesMiddleware, listReadOnlyOperationMiddlewareafter it (as shown above). APQ materialises the query body from the cache; running this middleware first against an APQ-only request would causeOperationParams::getParsedQuery()to throwNo GraphQL query available. The ordering relative toGraphqlExecutionMiddlewareis enforced by the framework, which always appendsGraphqlExecutionMiddlewarelast.
When your GraphQL endpoint uses cookie-based authentication (Laravel sessions,
Sanctum cookie-mode), it is vulnerable to Cross-Site Request Forgery. A
malicious page can submit a form POST to your /graphql endpoint and the
browser will attach the session cookie automatically -- executing mutations on
behalf of the logged-in user.
This does not apply when authentication uses explicit credentials only (Bearer tokens, API keys) because browsers never attach those automatically.
The CsrfGuard middleware protects against this by ensuring that every request
either came from the same origin (verified via the browser's Sec-Fetch-Site
header), or carries indicators that would have forced a CORS preflight (custom
headers, non-simple Content-Type):
use Rebing\GraphQL\Support\Middleware\CsrfGuard;
// config/graphql.php — apply to all schemas:
'route' => [
'middleware' => [CsrfGuard::class],
],
// Or per-schema with custom options:
'schemas' => [
'admin' => [
'middleware' => [CsrfGuard::class], // strict defaults
],
'public' => [
'middleware' => [
CsrfGuard::using(strictWhenAmbiguous: false), // allow non-browser clients
],
],
],
The middleware is opt-in and has strict safe defaults. See the PHPDoc on
CsrfGuard::using() for a full explanation of each configurable flag.
// config/graphql.php
'security' => [
'disable_introspection' => env('GRAPHQL_DISABLE_INTROSPECTION', true),
'query_max_depth' => 13,
'query_max_complexity' => 500,
],
'batching' => [
'enable' => false,
],
Additional measures to consider at the infrastructure level:
ThrottleRequests middleware via
route.middleware or per-schema middleware to limit requests per client.client_max_body_size,
Apache LimitRequestBody) to reject oversized request bodies.max_execution_time and web server timeouts to
prevent long-running queries from holding connections open.This library has two distinct error layers:
Rebing\GraphQL\Error\*): Extend GraphQL\Error\Error from
webonyx/graphql-php. These are client-safe and appear in GraphQL JSON
responses.Rebing\GraphQL\Exception\*): Extend RuntimeException.
These indicate configuration or developer errors (e.g. a missing schema or
unregistered type) and are not included in GraphQL responses.| Class | Category | When thrown |
|---|---|---|
ValidationError |
validation |
Argument validation rules fail (via rules() or inline 'rules' key) |
AuthorizationError |
authorization |
authorize() returns anything other than true |
AutomaticPersistedQueriesError |
apq |
APQ hash mismatch, query not found, or APQ disabled |
Errors are returned in the standard GraphQL errors array. The library enriches
each error with an extensions key:
{
"errors": [
{
"message": "validation",
"extensions": {
"category": "validation",
"validation": {
"email": ["The email field is required."]
}
},
"locations": [{"line": 1, "column": 20}]
}
]
}
For AuthorizationError, the response contains extensions.category set to
"authorization" and the message from getAuthorizationMessage() (defaults
to "Unauthorized").
The default errors_handler selectively reports errors to Laravel's exception
handler:
ValidationError and AuthorizationError are not reported (they are
expected application-level errors, not bugs).ExceptionHandler, which typically logs them.You can replace the default error formatter and/or error handler via config:
// config/graphql.php
// Receives each GraphQL\Error\Error; must return an array
'error_formatter' => [App\GraphQL\ErrorFormatter::class, 'format'],
// Receives all errors + the formatter; must return an array of formatted errors
'errors_handler' => [App\GraphQL\ErrorHandler::class, 'handle'],
The default formatter (GraphQL::formatError) respects app.debug: when debug
mode is enabled, errors include debugMessage and trace fields for easier
development. In production these are omitted.
Tip: Laravel's built-in
ValidationException(thrown byValidator::validate()) is also handled by the default formatter -- it is automatically converted to the sameextensions.validationformat shown above.
By default, 'variables' provided alongside the GraphQL query which are not
consumed, are silently ignored.
If you consider the hypothetical case you have an optional (nullable) argument in your query, and you provide a variable argument for it but you make a typo, this can go unnoticed.
Example:
mutation test($value:ID) {
someMutation(type:"falbala", optional_id: $value)
}
Variables provided:
{
// Ops! typo in `values`
"values": "138"
}
In this case, nothing happens and optional_id will be treated as not being provided.
To prevent such scenarios, you can add the UnusedVariablesMiddleware to your
execution_middleware.
| Option | Default | Description |
|---|---|---|
route.prefix |
graphql |
URL prefix for GraphQL endpoints (without leading /) |
route.controller |
Built-in | Override the default controller class (supports string and array format) |
route.middleware |
[] |
Global HTTP middleware for all schemas (unless overridden per-schema) |
route.group_attributes |
[] |
Additional route group attributes |
default_schema |
'default' |
Name of the default schema when none is specified via the route |
batching.enable |
false |
Enable/disable GraphQL batching |
batching.max_batch_size |
10 |
Max operations per batch (null for no limit) |
error_formatter |
Built-in | Callable receiving each Error object; must return an array |
errors_handler |
Built-in | Custom error handling; default passes exceptions to Laravel's error handler |
security.query_max_complexity |
500 |
Maximum allowed query complexity. See graphql-php security docs |
security.query_max_depth |
13 |
Maximum allowed query depth |
security.disable_introspection |
true |
Disable schema introspection (env: GRAPHQL_DISABLE_INTROSPECTION) |
pagination_type |
Built-in | Custom pagination type class |
simple_pagination_type |
Built-in | Custom simple pagination type class |
cursor_pagination_type |
Built-in | Custom cursor pagination type class |
defaultFieldResolver |
null |
Override the default field resolver |
headers |
[] |
Headers added to responses from the default controller |
json_encoding_options |
0 |
JSON encoding options for responses from the default controller |
apq.enable |
false |
Enable Automatic Persisted Queries |
apq.cache_driver |
App default | Cache driver for APQ (defaults to your app's cache.default driver; env: GRAPHQL_APQ_CACHE_DRIVER) |
apq.cache_prefix |
'{cache.prefix}:graphql.apq' |
Cache key prefix for persisted queries |
apq.cache_ttl |
300 |
Cache TTL in seconds for persisted queries |
schemas |
Defines available schemas and their settings. See Schemas | |
schemas.*.query |
[] |
Array of query classes for this schema |
schemas.*.mutation |
[] |
Array of mutation classes for this schema |
schemas.*.types |
[] |
Array of type classes scoped to this schema |
schemas.*.middleware |
- | Per-schema HTTP middleware (overrides route.middleware) |
schemas.*.method |
['POST'] |
HTTP methods to support (must be uppercase) |
schemas.*.execution_middleware |
- | Per-schema execution middleware (overrides global execution_middleware) |
schemas.*.route_attributes |
[] |
Additional Laravel route attributes (e.g. domain, prefix) |
schemas.*.controller |
- | Override the controller for this schema |
schemas.*.tracing |
- | Per-schema tracing overrides |
types |
[] |
Global types shared across all schemas. See Creating a query |
execution_middleware |
Built-in set | Global execution middleware classes. Terminal middleware is always appended automatically |
resolver_middleware_append |
null |
Global resolver middleware appended after per-field middleware |
tracing.driver |
null |
Tracing driver class (null = disabled). Built-in: OpenTelemetryTracingDriver. See Tracing |
tracing.field_tracing |
false |
Instrument individual field resolvers |
tracing.driver_options |
[] |
Array of options passed to the driver constructor (e.g. 'include_document' => true) |
You can wrap types to add more information to the queries and mutations. Similar to how pagination works, you can do the same with your extra data that you want to inject. For instance, in your query:
public function type(): Type
{
return GraphQL::wrapType(
'PostType',
'PostMessageType',
\App\GraphQL\Types\WrapMessagesType::class,
);
}
public function resolve($root, array $args)
{
return [
'data' => Post::find($args['post_id']),
'messages' => new Collection([
new SimpleMessage("Congratulations, the post was found"),
new SimpleMessage("This post cannot be edited", "warning"),
]),
];
}
If you use the rebing/graphql-laravel-select-fields
package with a query that returns a wrap type, your wrapper class must implement
the WrapType marker interface from that package. See the SelectFields package
documentation for details.
rebing/graphql-laravel-select-fields)You can interact with your GraphQL API using any of these clients:
| Client | Notes |
|---|---|
| GraphiQL | The reference GraphQL IDE. Use the laravel-graphiql package for seamless in-app integration (/graphiql route) |
| Altair | Feature-rich desktop/browser client with file upload support, environments, and pre-request scripts |
| Postman | Has native GraphQL support with schema introspection, auto-complete, and variable management |
| Insomnia | Lightweight REST/GraphQL client with schema fetching and query auto-complete |
| Bruno | Open-source, offline-first API client with GraphQL support. Collections are stored as files, making them easy to version control |
Tip: Most of these clients rely on schema introspection. Introspection is disabled by default in this package. Set
GRAPHQL_DISABLE_INTROSPECTION=falsein your.envduring development to enable it.
You can test your GraphQL API using Laravel's built-in HTTP testing helpers. No additional packages are required.
Use postJson to send a GraphQL request and assert the response:
namespace Tests\Feature;
use Tests\TestCase;
class BooksQueryTest extends TestCase
{
public function test_can_query_books(): void
{
$response = $this->postJson('/graphql', [
'query' => '{ books { id title author } }',
]);
$response->assertOk()
->assertJsonStructure([
'data' => [
'books' => [
'*' => ['id', 'title', 'author'],
],
],
]);
}
}
public function test_can_fetch_user_by_id(): void
{
$response = $this->postJson('/graphql', [
'query' => 'query FetchUser($id: String!) { user(id: $id) { id email } }',
'variables' => ['id' => '1'],
]);
$response->assertOk()
->assertJsonPath('data.user.id', '1');
}
public function test_can_update_user_password(): void
{
$response = $this->postJson('/graphql', [
'query' => 'mutation { updateUserPassword(id: "1", password: "newpassword") { id } }',
]);
$response->assertOk()
->assertJsonPath('data.updateUserPassword.id', '1');
}
Pass the schema name as part of the URL path:
public function test_user_schema_requires_auth(): void
{
$response = $this->postJson('/graphql/user', [
'query' => '{ profile { id email } }',
]);
$response->assertUnauthorized();
}
public function test_authorization_rejects_guest(): void
{
$response = $this->postJson('/graphql', [
'query' => '{ protectedQuery { id } }',
]);
$response->assertOk()
->assertJsonPath('errors.0.message', 'Unauthorized');
}
public function test_validation_returns_errors(): void
{
$response = $this->postJson('/graphql', [
'query' => 'mutation { updateUserEmail(id: "", email: "not-an-email") { id } }',
]);
$response->assertOk()
->assertJsonPath('errors.0.message', 'validation')
->assertJsonStructure([
'errors' => [
['extensions' => ['validation']],
],
]);
}
Tip: For database-backed tests, use Laravel's
RefreshDatabaseorDatabaseTransactionstrait as you would in any feature test.
For upgrade guides, see UPGRADE.md:
This project was originally forked from Folklore's laravel-graphql.
How can I help you explore Laravel packages today?