webonyx/graphql-php
A PHP implementation of the GraphQL specification, based on the JavaScript reference implementation. It's fully compliant with the October 2021 spec, with September 2025 compliance in progress, and supports schema building, query execution, and validation.
Install the package:
composer require webonyx/graphql-php
Define a basic schema (e.g., app/GraphQL/Schema.php):
use GraphQL\Type\Definition\Type;
use GraphQL\Type\Schema;
use GraphQL\Type\Definition\ObjectType;
$queryType = new ObjectType([
'name' => 'Query',
'fields' => [
'hello' => [
'type' => Type::string(),
'resolve' => fn() => 'World',
],
],
]);
return new Schema(['query' => $queryType]);
Create a GraphQL route (e.g., routes/web.php):
use GraphQL\Server\ServerConfig;
use GraphQL\Server\StandardServer;
Route::post('/graphql', function () {
$config = ServerConfig::create()
->setSchema(app('graphql.schema'))
->setContext(fn() => ['user' => auth()->user()]);
return (new StandardServer($config))->executeRequest();
});
Test with a query:
query {
hello
}
use App\Models\User;
use GraphQL\Type\Definition\ObjectType;
use GraphQL\Type\Definition\Type;
$schema = new Schema([
'query' => new ObjectType([
'name' => 'Query',
'fields' => [
'user' => [
'type' => Type::string(),
'args' => [
'id' => ['type' => Type::int()]
],
'resolve' => function ($root, $args) {
return User::find($args['id'])->name;
}
]
]
])
]);
Three day-to-day relevant changes:
Printer::stripIgnoredCharacters(string $document): string (v15.38.0) — returns the document with insignificant whitespace, commas and comments removed. This is exactly what you want before hashing a query for persisted-query lookup, so cosmetic client-side formatting doesn't create cache misses.#1987) rather than silently producing mangled output. Any raw request body / query string that isn't valid UTF-8 now surfaces a proper error at parse time — useful, but it can make previously "working" clients start failing loudly.RequestError, not TypeError (v15.38.1, #1993). If a client posts a batch array containing a scalar instead of an operation object — ["x"], or {"query":"{ a }","0":"x"} — the server now raises a GraphQL\Error\RequestError, which the server converts into a proper 4xx GraphQL error response. Previously this leaked a PHP TypeError (a 500 in most Laravel setups). If you catch (\TypeError) around batch execution, that catch is now dead code.Modular Schema Construction
Use a Schema Factory (e.g., app/GraphQL/SchemaFactory.php) to build schemas incrementally:
class SchemaFactory {
public static function build(): Schema {
$query = new ObjectType([
'name' => 'Query',
'fields' => [
'users' => [
'type' => Type::listOf(UserType::get()),
'resolve' => fn() => User::all()
],
],
]);
return new Schema(['query' => $query]);
}
}
Type Registry
Centralize type definitions in a Type Registry (e.g., app/GraphQL/Types/):
// app/GraphQL/Types/UserType.php
class UserType extends ObjectType {
public static function get(): ObjectType {
return new self([
'name' => 'User',
'fields' => [
'id' => ['type' => Type::int()],
'name' => ['type' => Type::string()]
]
]);
}
}
Input Types for Mutations
Define input types for mutations (e.g., CreateUserInput):
$createUserInput = new InputObjectType([
'name' => 'CreateUserInput',
'fields' => [
'name' => ['type' => Type::nonNull(Type::string())],
'email' => ['type' => Type::nonNull(Type::string())]
]
]);
$mutation = new ObjectType([
'name' => 'Mutation',
'fields' => [
'createUser' => [
'type' => UserType::get(),
'args' => ['input' => ['type' => $createUserInput]],
'resolve' => function ($root, $args) {
return User::create($args['input']);
}
]
]
]);
Dependency Injection in Resolvers Use Laravel's service container to inject dependencies:
'resolve' => function ($root, $args, $context) {
return $context['userRepository']->find($args['id']);
}
Batch Loading
Optimize N+1 queries with a DataLoader (e.g. overblog/dataloader-php or a hand-rolled buffer resolved per request):
use GraphQL\Deferred;
$buffer = [];
$loader = function ($ids) use (&$buffer) {
// Collect ids during the execution tick, resolve once
return User::whereIn('id', $buffer)->get()->keyBy('id');
};
'resolve' => function ($root, $args) use ($loader) {
return new Deferred(fn() => $loader($args['id']));
}
Error Handling
Throw GraphQL\Error\Error for client-safe messages; let everything else bubble up and be masked in production:
'resolve' => function ($root, $args) {
$user = User::find($args['id']);
if ($user === null) {
throw new \GraphQL\Error\Error('User not found', extensions: ['code' => 'NOT_FOUND']);
}
return $user;
}
Service Provider
Bind the schema in AppServiceProvider:
public function register() {
$this->app->singleton('graphql.schema', fn() => SchemaFactory::build());
}
Middleware for Authentication Attach middleware to the GraphQL route:
Route::post('/graphql', function () {
return app('graphql.server')->executeRequest();
})->middleware('auth:api');
Context Injection Pass Laravel's request or authenticated user:
$config = ServerConfig::create()
->setSchema(app('graphql.schema'))
->setContext(fn() => ['user' => auth()->user(), 'request' => request()]);
Persisted Queries — normalize before hashing
Printer::stripIgnoredCharacters() removes whitespace, commas and comments, so two clients sending the "same" query with different formatting map to the same cache key:
use GraphQL\Language\Parser;
use GraphQL\Language\Printer;
$normalized = Printer::stripIgnoredCharacters($incomingQuery);
$hash = hash('sha256', $normalized);
$document = Cache::rememberForever("gql:query:{$hash}", function () use ($incomingQuery) {
return $incomingQuery; // or the AST, or the SDL source
});
$serverConfig->setPersistedQueryLoader(fn($queryId) => Cache::get("gql:query:{$queryId}"));
Note: strip is purely lexical — it does not reorder fields or rename variables, so variable-bearing documents still need their variables passed separately.
Query Complexity Validation Limit query depth/complexity to prevent expensive queries:
use GraphQL\Validator\Rules\QueryComplexity;
use GraphQL\Validator\DocumentValidator;
DocumentValidator::addRule(new QueryComplexity(1000));
Lazy Loading Types Defer type loading for large schemas:
$schema = new Schema([
'query' => fn() => $queryType,
'types' => fn() => [$userType, $postType]
]);
Circular Dependencies in Resolvers
User resolver calling Post resolver which calls User again).Deferred for batch loading, or return a lazy fields closure so types are only linked at execution time.Type Mismatches in Arguments
Type::nonNull(Type::int()) for required arguments and validate input:
'args' => [
'id' => ['type' => Type::nonNull(Type::int())],
'name' => ['type' => Type::string()]
]
Nested Resolver Errors
GraphQL\Error\Error and add a custom error formatter so extensions.code survives into the response.Schema Validation Failures
$schema->assertValid() in a test or in a php artisan graphql:validate-style command:
$schema = new Schema([...]);
$schema->assertValid();
Performance with Large Datasets
all()) cause memory issues.query {
users(first: 10, after: "cursor") {
edges { node { id name } }
pageInfo { hasNextPage }
}
}
Invalid UTF-8 now hard-fails (v15.38.0)
Content-Type charset is UTF-8:
if (! mb_check_encoding($query, 'UTF-8')) {
throw new \GraphQL\Error\Error('Query must be valid UTF-8');
}
stripIgnoredCharacters() is not a sanitizer
Batch requests now fail with RequestError (v15.38.1)
["x"]), or a JSON object that got coerced into an array by PHP's decoder ({"query":"{ a }","0":"x"}), used to blow up as a TypeError — a 500 in most Laravel setups, and invisible to your GraphQL error formatter.GraphQL\Error\RequestError, which is handled like any other request-level problem and produces a proper client error response.catch (\TypeError $e) guards around batch execution — they no longer fire.RequestError, verify the batch case now lands there too; extensions.code for these is a request error, not a resolver error.json_decode($body, true) turns {"query":"...","0":"x"} into a mixed array, guard the shape yourself before execution if you want a friendlier message than the default:
$payload = json_decode($request->getContent(), true);
$isBatch = array_is_list($payload);
if ($isBatch && collect($payload)->contains(fn ($op) => ! is_array($op))) {
throw new \GraphQL\Error\RequestError('Each batch entry must be an operation object');
}
ServerConfig does by default) — if you've disabled batching or always send single operations, nothing changes for you.Enable Debug Mode Configure the server to return detailed errors:
$config = ServerConfig::create()
->setSchema(app('graphql.schema'))
->setDebugFlag(app()->environment('local'))
->setErrorFormatter(fn(\GraphQL\Error\Error $e) => \GraphQL\Error\FormattedError::createFromException($e, app()->environment('local')));
Log the raw body when a parse error looks impossible
With UTF-8 rejection in place, a "syntax error" is often a transport problem. Dump request()->getContent() with bin2hex() around the failure offset before blaming the query.
Distinguish request errors from execution errors
Since v15.38.1, malformed batch entries surface as RequestError rather than a PHP TypeError. If a batch request "used to 500 and now returns 400", that's the fix working — don't chase it as a regression. Log get_class($e) in your formatter when triaging.
Assert schema validity in CI, not at runtime
Add $schema->assertValid() to a single test — it catches duplicate types, missing interfaces and bad field definitions long before a user does.
Use the printer for diffing
Printer::doPrint() plus stripIgnoredCharacters() gives you a stable, whitespace-insensitive form — handy for snapshot tests and for eyeballing what a client actually persisted.
How can I help you explore Laravel packages today?