ez-php / graphql
GraphQL module for the ez-php framework — schema builder, query executor, and HTTP endpoint via webonyx/graphql-php
Requires
- php: ^8.5
- ez-php/contracts: ^2.7.0
- ez-php/dataloader: ^2.7.0
- ez-php/http: ^2.7.0
- webonyx/graphql-php: ^15.0
Requires (Dev)
- ez-php/cache: ^2.7.0
- ez-php/docker: ^2.7.0
- friendsofphp/php-cs-fixer: ^3.94
- phpstan/phpstan: ^2.1
- phpstan/phpstan-deprecation-rules: ^2.0
- phpstan/phpstan-strict-rules: ^2.0
- phpunit/phpunit: ^13.0
Suggests
- ez-php/cache: Needed for Automatic Persisted Queries (PersistedQueryStore)
Provides
None
Conflicts
None
Replaces
None
- dev-main
- 2.7.1
- 2.7.0
- 2.6.5
- 2.6.4
- 2.6.3
- 2.6.2
- 2.6.1
- 2.6.0
- 2.5.10
- 2.5.9
- 2.5.8
- 2.5.7
- 2.5.6
- 2.5.5
- 2.5.4
- 2.5.3
- 2.5.2
- 2.5.1
- 2.5.0
- 2.4.11
- 2.4.10
- 2.4.9
- 2.4.8
- 2.4.7
- 2.4.6
- 2.4.5
- 2.4.4
- 2.4.3
- 2.4.2
- 2.4.1
- 2.4.0
- 2.3.9
- 2.3.8
- 2.3.7
- 2.3.6
- 2.3.5
- 2.3.4
- 2.3.3
- 2.3.2
- 2.3.1
- 2.3.0
- 2.2.1
- 2.2.0
- 2.1.1
- 2.1.0
- 2.0.1
- 2.0.0
- 1.14.0
- 1.13.1
- 1.13.0
- 1.12.2
- 1.12.1
- 1.12.0
- 1.11.2
- 1.11.1
- 1.11.0
- 1.10.0
- 1.9.2
- 1.9.1
- 1.9.0
- 1.8.0
- 1.7.1
- 1.7.0
- 1.6.1
- 1.6.0
- 1.5.1
- 1.5.0
- 1.4.2
- 1.4.1
- 1.4.0
- 1.3.0
- 1.2.0
- 1.0.0
This package is auto-updated.
Last update: 2026-10-30 15:48:09 UTC
README
GraphQL module for the ez-php framework. Provides a POST /graphql HTTP endpoint, schema builder, query executor, and static facade — powered by webonyx/graphql-php.
Installation
composer require ez-php/graphql
Setup
1. Define your schema
Create a service provider that binds a GraphQL\Type\Schema:
// app/Providers/GraphQLSchemaProvider.php use EzPhp\Contracts\ServiceProvider; use EzPhp\GraphQL\SchemaBuilder; use GraphQL\Type\Definition\Type; use GraphQL\Type\Schema; final class GraphQLSchemaProvider extends ServiceProvider { public function register(): void { $this->app->bind(Schema::class, function (): Schema { return SchemaBuilder::create() ->query([ 'hello' => [ 'type' => Type::string(), 'resolve' => fn(): string => 'Hello, World!', ], 'user' => [ 'type' => Type::string(), 'args' => ['id' => ['type' => Type::nonNull(Type::id())]], 'resolve' => fn($root, array $args): string => 'User ' . $args['id'], ], ]) ->build(); }); } public function boot(): void {} }
2. Register providers
In provider/modules.php, register your schema provider before GraphQLServiceProvider:
$app->register(GraphQLSchemaProvider::class); $app->register(\EzPhp\GraphQL\GraphQLServiceProvider::class);
HTTP Endpoint
POST /graphql — accepts JSON:
{
"query": "{ hello }",
"variables": {},
"operationName": null
}
Response:
{
"data": {
"hello": "Hello, World!"
}
}
Recipes
N+1 batching with ez-php/dataloader
ez-php/graphql depends on ez-php/dataloader and ships DataLoaderRegistry — a
request-scoped, keyed registry of DataLoader instances, so resolvers that batch the same
kind of data (e.g. a post.author field and a comment.author field, both loading User
rows) share one loader and one batch call instead of each maintaining its own.
Construct one registry per request and pass it through as webonyx's execution context so
every resolver can reach it via $context:
use EzPhp\GraphQL\DataLoaderRegistry; use EzPhp\GraphQL\SchemaBuilder; use GraphQL\Type\Definition\Type; $schema = SchemaBuilder::create() ->query([ 'post' => [ 'type' => Type::string(), 'args' => ['id' => ['type' => Type::nonNull(Type::id())]], 'resolve' => function ($root, array $args, DataLoaderRegistry $context) use ($db): array { $post = findPost($args['id']); // $db: an injected EzPhp\Contracts\DatabaseInterface; one placeholder per id $userLoader = $context->get('users', fn(array $ids): array => array_column( $db->query( 'SELECT * FROM users WHERE id IN (' . implode(', ', array_fill(0, count($ids), '?')) . ')', array_values($ids), ), null, 'id', )); // queues the author id; the batch call only fires once every // sibling field in this selection set has queued its own key $authorDeferred = $userLoader->load($post['author_id']); return ['title' => $post['title'], 'author' => $authorDeferred->get()]; }, ], ]) ->build(); // per request: $registry = new DataLoaderRegistry(); $executor->execute($query, $variables, context: $registry);
DataLoaderRegistry::get(key, batchLoadFn) creates a DataLoader on first call for a key
and returns the same instance on every later call for that key within the same registry —
the batch function passed on a later call is ignored, since the first resolver to reach a
key defines it. Deferred::get() triggers dispatch() the first time a value is actually
needed, so N sibling posts resolving the same users loader produce one query for all
their authors instead of N queries. See modules/dataloader/README.md for DataLoader's
full API (prime()/clear()/clearAll(), the batch function contract).
Always construct a fresh DataLoaderRegistry per request and discard it afterward — a
shared, long-lived instance (e.g. a container singleton) would leak cached values and
pending batch keys across unrelated requests. DataLoaderRegistry is deliberately not
wired into GraphQLServiceProvider for this reason; pass a fresh one as execute()'s
$context argument yourself, per request, from your own controller or middleware.
Subscriptions
SchemaBuilder::subscription() adds a Subscription root type. Delivering subscription
results over a persistent connection is not this module's job: ez-php/graphql-subscriptions
runs the graphql-transport-ws protocol on ez-php/websocket and feeds subscriptions from
ez-php/broadcast events. A subscription field resolves from the broadcast payload, which
arrives as the root value:
$schema = SchemaBuilder::create() ->query([...]) ->subscription([ 'commentCreated' => [ 'type' => Type::string(), 'args' => ['postId' => ['type' => Type::nonNull(Type::id())]], 'resolve' => fn (array $payload): string => $payload['body'], ], ]) ->build();
GraphQLExecutor::execute() accepts that root value as its fifth argument (rootValue:),
and GraphQLExecutor::validate($query) checks a document — including the depth and
complexity limits — without executing it.
Static Facade
use EzPhp\GraphQL\GraphQL; $result = GraphQL::execute('{ hello }'); // ['data' => ['hello' => 'Hello, World!']] $result = GraphQL::execute( 'query GetUser($id: ID!) { user(id: $id) }', ['id' => '42'], );
Schema Builder
SchemaBuilder wraps webonyx's schema API for common cases:
use EzPhp\GraphQL\SchemaBuilder; use GraphQL\Type\Definition\Type; $schema = SchemaBuilder::create() ->query([ 'posts' => [ 'type' => Type::listOf(Type::string()), 'resolve' => fn(): array => ['Post 1', 'Post 2'], ], ]) ->mutation([ 'createPost' => [ 'type' => Type::string(), 'args' => ['title' => ['type' => Type::nonNull(Type::string())]], 'resolve' => fn($root, array $args): string => $args['title'], ], ]) ->build();
For advanced schemas (interfaces, unions, enums, custom scalars) construct the webonyx Schema directly.
Configuration
Optional config/graphql.php:
return [ // URI for the GraphQL endpoint. Default: '/graphql' 'endpoint' => '/graphql', // Limits against expensive documents (0 disables). Defaults: 15 / 200. 'max_query_depth' => 15, 'max_query_complexity' => 200, // Automatic Persisted Queries (needs a bound ez-php/cache CacheInterface). Default: off. 'persisted_queries' => false, 'persisted_queries_ttl' => 0, // seconds a stored query lives; 0 = no expiry ];
Persisted queries
With persisted_queries => true the endpoint speaks the Apollo APQ protocol: a client may send extensions.persistedQuery = {version: 1, sha256Hash} instead of the query text.
| Request | Result |
|---|---|
| hash only, known | the stored query runs |
| hash only, unknown | PERSISTED_QUERY_NOT_FOUND — the client resends hash and text |
| hash + text | the hash must be the SHA-256 of the text (else PERSISTED_QUERY_HASH_MISMATCH); the query is stored and runs |
| hash while the feature is off | PERSISTED_QUERY_NOT_SUPPORTED — the client falls back to full queries |
Queries larger than 64 KiB run but are not stored. The store is PersistedQueryStore (entries are graphql:apq:<sha256> in the cache).
Debug mode is read from app.debug. When enabled, error responses include debugMessage and stack traces.
Error handling
GraphQL-level errors (unknown fields, failed resolvers) are returned with HTTP 200 in the errors array, per the GraphQL spec:
{
"errors": [
{ "message": "Cannot query field \"nonexistent\" on type \"Query\"." }
]
}
A missing or empty query field returns HTTP 400.
Testing
No external services required.
composer test
License
MIT