GraphQL
|
This content was generated with the assistance of AI and should be verified against the official Drupal documentation before being relied on in production. This sub-section’s Drupal bibliography lists the reference material consulted while preparing these pages. |
GraphQL is a query language and runtime for APIs: a client sends one request that names exactly the fields it wants,
and the server answers with a JSON document of the same shape. Drupal core does not ship GraphQL. It ships
JSON:API and REST, and GraphQL comes from
the contributed GraphQL module. This page explains what the module does, how
a request travels through it, how to write a schema and a schema extension for the Meridian Museum’s
meridian_collection module, how to write a custom data producer, how to secure and cache the endpoint, and when
JSON:API is the better choice.
The page was checked on 2026-10-30 against Drupal 11.4.x (Drush 13/14, PHP 8.3+) and the GraphQL module. The project page lists GraphQL 5.1.0 (released 2026-09-15, Drupal ^10.4 || ^11) as the current 5.x release, 4.14 (released 2026-04-29, Drupal ^10.2 || ^11) as the current 4.x release and 3.9 (released 2026-04-15) as a branch in maintenance mode. The 5.x branch is the one to use for a new Drupal 11 site; 4.x is still released and still supported, and the differences between the two are small (see Version notes). The module’s own example module declares compatibility with Drupal 12, while the project page lists only Drupal 10.4 and 11 for 5.1.0, so check the release page again once Drupal 12.0.0 has shipped (it is scheduled for the week of 2026-12-07).
What the GraphQL module is and who uses it
The module wraps the PHP library webonyx/graphql-php (which implements the GraphQL specification) in a set of Drupal concepts:
-
a server is a configuration entity that binds a URL (the endpoint, for example
/graphql) to a schema and to a few options (caching, batching, introspection, depth and complexity limits) -
a schema is a plugin that provides the type definitions and the resolvers for them
-
a schema extension is a plugin, usually in another module, that adds types and resolvers to a schema
-
a data producer is a plugin that resolves one piece of data (load an entity, read a field, follow a reference, load a route) and that can be chained with other producers
-
a persisted query is a query stored on the server and referred to by an identifier
Three kinds of people use it. A front-end developer writes queries against the endpoint, usually from a Next.js, Vue or mobile application (see Decoupled Drupal with Next.js). A Drupal developer designs the schema and writes the resolvers. An administrator creates the server, assigns the permissions and decides which operations are allowed. A site builder who only needs to expose existing content to a client application usually does not need any of this and starts with JSON:API.
The documentation lives in two places. The drupal.org handbook page
GraphQL is an overview that points to the project’s GitBook
(https://drupal-graphql.gitbook.io/graphql/), which has separate sections for 3.x, 4.x and 5.x. The project’s
examples directory in the 5.x branch holds the example modules the GitBook refers to.
Versions: 3.x, 4.x and 5.x
The three branches differ mostly in who designs the schema.
| Branch | Schema design | Notes |
|---|---|---|
3.x |
The module generated a schema from Drupal entities, with query, filter and mutation plugins |
Maintenance mode. Most blog posts and the older book chapters describe this model |
4.x |
Schema first: the developer writes the schema in SDL files and registers resolvers |
Drupal ^10.2 || ^11. Plugin definitions use docblock annotations. Still released (4.14) |
5.x |
Schema first, same model as 4.x, on webonyx/graphql-php 15 with strict types |
Drupal ^10.4 || ^11. Plugin definitions can use PHP attributes; annotations still work |
Older books such as the Drupal 10 Development Cookbook (2023) cover REST and JSON:API as the core ways to build an
API, and tutorials found on the web often describe the 3.x model where the module generated the whole
schema for you. In 4.x and 5.x that is no longer the case: the module exposes only what the schema declares. Two
contributed projects generate a schema automatically on top of the module for sites that want the old convenience:
GraphQL Compose (2.4.2 is the current stable release, built for
GraphQL 4.x and 5.x) and GraphQL Core Schema. The same books also show plugin annotations (@Block, @DataProducer
in docblocks); since Drupal 10.2 core plugins use attributes, and since GraphQL 5.0 the module’s own plugins can be
defined with the #[Schema], #[SchemaExtension], #[DataProducer] and #[PersistedQuery] attributes
(see Plugin API and attributes).
How a request is resolved
The executor parses the query, validates it against the schema, and walks the selection set. For every field it asks
the resolver registry which resolver is registered for the pair "type name, field name". The resolver is a data
producer, or a chain of producers, built with the ResolverBuilder. The values returned by the producers are then
turned into the JSON response, and the cache metadata (tags, contexts, max-age) that the producers collected is
attached to the response. Entity loading goes through buffers, so that a list of fifty exhibitions that each ask for
their related objects causes batched loads instead of fifty separate queries (the producers return Deferred
objects that the buffers resolve together).
Installing the module and creating a server
Install the module with Composer, and enable it with Drush. The module pulls in webonyx/graphql-php and the Typed Data module as dependencies.
composer require 'drupal/graphql:^5.1'
drush en graphql graphql_examples -y
drush cache:rebuild
The graphql_examples module is optional and provides an example schema over the Article content type. Install it on
a development site only, to see a working schema, and uninstall it afterwards. A site that stays on 4.x requires
'drupal/graphql:^4.14' instead.
A server is created under Configuration > Web services > GraphQL (/admin/config/graphql). The form asks for:
-
a label and machine name
-
the schema plugin the server uses
-
the endpoint, the path that serves GraphQL requests (for example
/graphql) -
Enable caching, Allow query batching and Disable introspection
-
Maximum query depth and Maximum query complexity (0 means unlimited)
-
Debug flags for development
Once saved, the server has an Explorer tab (GraphiQL, an in-browser IDE with autocompletion), an optional Voyager tab (a graph view of the types) and a Persisted queries tab. The server is a configuration entity, so it is exported with the rest of the site configuration (see Configuration management). An exported server for the museum looks like this:
# config/sync/graphql.graphql_servers.meridian.yml
langcode: en
status: true
dependencies:
module:
- meridian_graphql
name: meridian
label: 'Meridian collection API'
schema: meridian
schema_configuration: { }
debug_flag: 0
caching: true
batching: true
disable_introspection: false
query_depth: 8
query_complexity: 200
endpoint: /graphql
persisted_queries_settings: { }
The keys are the ones the module’s configuration schema declares: name, label, schema, schema_configuration,
debug_flag, caching, batching, disable_introspection, query_depth, query_complexity, endpoint and
persisted_queries_settings. A production export keeps debug_flag at 0 so that error responses carry no stack
traces.
Permissions and who may query
The module defines two fixed permissions and two permissions per server:
-
administer graphql configurationcreates, edits and deletes servers (restricted: give it to trusted administrators only) -
bypass graphql accesslets a user use any server regardless of its permission restrictions (restricted) -
execute meridian arbitrary graphql requestslets a role send any query to themeridianserver -
execute meridian persisted graphql requestslets a role send only queries that were stored in advance
The last two are generated from the server’s machine name, so the server meridian yields the two permissions shown.
The Explorer and Voyager pages have their own access checks, and the module’s permissions page controls who may open them. For a public
museum site the usual choice is to grant the persisted permission to the anonymous role and the arbitrary
permission only to developers' roles, which means that production clients can run only the queries the team has
reviewed. See Users, roles and permissions.
Authentication for logged-in clients is not part of the module. A decoupled application usually authenticates with OAuth (see Authentication and OAuth) and the GraphQL request then runs as that user, so the same entity access rules apply. A browser application on another origin also needs CORS configured (see CORS).
Schema first: SDL files and the schema plugin
In 5.x you write the schema in the GraphQL schema definition language (SDL) and then say, field by field, where the
data comes from. The documentation recommends starting from the example modules in the project’s examples directory
and copying them into a module of your own. For the museum the schema lives in a small module, meridian_graphql, so
that every other module can extend it.
# web/modules/custom/meridian_graphql/meridian_graphql.info.yml
name: 'Meridian GraphQL'
type: module
description: 'GraphQL schema for the Meridian Museum collection API.'
package: Meridian
core_version_requirement: ^10.4 || ^11
dependencies:
- graphql:graphql
- drupal:node
The schema plugin’s definition file sits in the module’s graphql directory and is named after the plugin ID:
graphql/meridian.graphqls for the plugin meridian.
# graphql/meridian.graphqls
schema {
query: Query
}
type Query {
exhibition(id: Int!): Exhibition
exhibitions(offset: Int = 0, limit: Int = 10): ExhibitionConnection!
}
type Exhibition {
id: Int!
title: String!
startsOn: String
endsOn: String
}
type ExhibitionConnection {
total: Int!
items: [Exhibition!]
}
Source: GraphQL module: example.graphqls
The type names and shapes are yours to choose. ! marks a non-null value, [Exhibition!] is a list of non-null
exhibitions, and the default values on offset and limit are part of the schema. Adding a field here makes it
visible to clients, but it resolves to null until a resolver is registered for it.
The schema plugin registers the resolvers:
<?php
declare(strict_types=1);
namespace Drupal\meridian_graphql\Plugin\GraphQL\Schema;
use Drupal\graphql\Attribute\Schema;
use Drupal\graphql\GraphQL\ResolverBuilder;
use Drupal\graphql\GraphQL\ResolverRegistryInterface;
use Drupal\graphql\Plugin\GraphQL\Schema\SdlSchemaPluginBase;
/**
* The Meridian Museum collection API schema.
*/
#[Schema(
id: 'meridian',
name: 'Meridian Museum schema',
)]
class MeridianSchema extends SdlSchemaPluginBase {
/**
* {@inheritdoc}
*/
protected function registerResolvers(ResolverRegistryInterface $registry): void {
$builder = new ResolverBuilder();
$registry->addFieldResolver('Query', 'exhibition',
$builder->produce('entity_load')
->map('type', $builder->fromValue('node'))
->map('bundles', $builder->fromValue(['exhibition']))
->map('id', $builder->fromArgument('id'))
);
$registry->addFieldResolver('Exhibition', 'id',
$builder->produce('entity_id')
->map('entity', $builder->fromParent())
);
$registry->addFieldResolver('Exhibition', 'title',
$builder->produce('entity_label')
->map('entity', $builder->fromParent())
);
foreach (['startsOn' => 'field_dates.value', 'endsOn' => 'field_dates.end_value'] as $field => $path) {
$registry->addFieldResolver('Exhibition', $field,
$builder->produce('property_path')
->map('type', $builder->fromValue('entity:node'))
->map('value', $builder->fromParent())
->map('path', $builder->fromValue($path))
);
}
}
}
Reading the resolvers from the top:
-
$builder->produce('entity_load')names a data producer by its plugin ID.map()connects each of the producer’s inputs (type,bundles,id) to a source. -
fromValue()supplies a constant,fromArgument()supplies a query argument, andfromParent()supplies the value the parent field resolved to. ForExhibition.titlethe parent is the node thatQuery.exhibitionloaded. -
fromValue(['exhibition'])restricts the load to one bundle, so asking for the ID of a news item returnsnullinstead of leaking another content type. -
property_pathreads a Typed Data property path, here the start and end of a date range field. The path names (field_dates.value,field_dates.end_value) depend on the field type; check them against the field definition.
The attribute class is Drupal\graphql\Attribute\Schema in 5.x. In 4.x the same class carries a docblock annotation
(@Schema) instead, and the registerResolvers() method is declared without the typed signature that 5.x uses.
Data producers
A data producer is a plugin (discovered in src/Plugin/GraphQL/DataProducer) with an ID, a description, a declared
output type (produces), declared inputs (consumes) and a resolve() method whose parameters have the names of the
inputs. The module ships many of them. Those used most often are:
| Producer ID | What it does |
|---|---|
|
Loads one entity by type and ID, optionally restricted to bundles and a language, with an access check (the default
checks the |
|
Load by UUID or by several IDs |
|
Read basic properties of an entity |
|
Read status, timestamps, the owner and the URL |
|
Runs an entity query with limit, offset, sorts and filtering conditions |
|
Renders an entity in a view mode, for fields that must return HTML |
|
Reads a Typed Data property path from a value |
|
Follows an entity reference field |
|
Resolve a path to a route and the entity behind it |
entity_load returns the entity wrapped in a deferred value and runs the access check when the deferred value is
resolved; an entity the current user may not view resolves to null and adds the access result’s cache metadata to
the response. Other producers expose an access input in the same way. Always leave access checking switched on unless
the field is genuinely public.
Composing producers
The output of one producer can feed the next. compose() runs the producers in order and passes each result to the
next one as fromParent():
$registry->addFieldResolver('CollectionObject', 'curatorName',
$builder->compose(
$builder->produce('entity_owner')
->map('entity', $builder->fromParent()),
$builder->produce('entity_label')
->map('entity', $builder->fromParent())
)
);
This is the pattern the module’s own example uses to turn an article into its author’s name: load the owner, then read its label. Composing small producers keeps each one testable and lets the buffers batch the loads across items.
Writing a custom data producer
When no built-in producer fits, write one. The museum wants a query that lists the objects currently on display. A data producer is a plugin class, so it can use services through dependency injection (see Services and dependency injection).
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\GraphQL\DataProducer;
use Drupal\Component\Plugin\Definition\PluginDefinitionInterface;
use Drupal\Core\Cache\RefinableCacheableDependencyInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\Core\Plugin\Context\ContextDefinition;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\graphql\Attribute\DataProducer;
use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase;
use GraphQL\Error\UserError;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Lists the collection objects that are currently on display.
*/
#[DataProducer(
id: 'meridian_objects_on_display',
name: new TranslatableMarkup('Objects on display'),
description: new TranslatableMarkup('Loads the collection objects marked as on display.'),
produces: new ContextDefinition(
data_type: 'entity:collection_object',
label: new TranslatableMarkup('Collection objects'),
multiple: TRUE,
),
consumes: [
'limit' => new ContextDefinition(
data_type: 'integer',
label: new TranslatableMarkup('Limit'),
required: FALSE,
),
],
)]
class ObjectsOnDisplay extends DataProducerPluginBase implements ContainerFactoryPluginInterface {
const MAX_LIMIT = 50;
public function __construct(
array $configuration,
string $pluginId,
PluginDefinitionInterface|array $pluginDefinition,
protected EntityTypeManagerInterface $entityTypeManager,
) {
parent::__construct($configuration, $pluginId, $pluginDefinition);
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
);
}
/**
* Resolves the list of objects.
*/
public function resolve(?int $limit, RefinableCacheableDependencyInterface $metadata): array {
$limit ??= 10;
if ($limit > self::MAX_LIMIT) {
throw new UserError(sprintf('Exceeded maximum query limit: %s.', self::MAX_LIMIT));
}
$storage = $this->entityTypeManager->getStorage('collection_object');
$ids = $storage->getQuery()
->accessCheck(TRUE)
->condition('on_display', 1)
->sort('accession_number')
->range(0, $limit)
->execute();
// Any change to a collection object invalidates this list.
$metadata->addCacheTags(['collection_object_list']);
return array_values($storage->loadMultiple($ids));
}
}
The points to notice:
-
The
idof the#[DataProducer]attribute is the name used in$builder->produce(). The class goes insrc/Plugin/GraphQL/DataProducerof the module, the directory the plugin manager scans. -
The names in
consumesare the names youmap()and the parameter names ofresolve(). Parameters typedRefinableCacheableDependencyInterfacereceive the cache metadata object, and aFieldContextparameter receives the field’s context. -
A limit is enforced and a
UserErroris thrown when it is exceeded. AUserErroris shown to the client as a normal GraphQL error, while any other exception is reported as an internal error unless debugging is enabled. -
The query calls
accessCheck(TRUE). Without it, entity queries are not access checked and can reveal unpublished or restricted objects. Fields that the query does not filter on (for example the entity’sviewaccess) still need an access check on load, or a filter in the query itself. -
The cache tag
collection_object_listis the list tag of the entity type; check the exact tag that the custom entity type from Custom entity types defines. -
The attribute form requires GraphQL 5.0 or later. On 4.x write the same definition as an
@DataProducerannotation.
Building a schema extension
A schema extension lets another module add types and resolvers to an existing schema. It is how the museum’s
meridian_collection module adds the collection to the API without touching meridian_graphql: the extension is
active whenever its module is enabled, and the API stops exposing the collection when the module is disabled. The
extension plugin is attached to a schema with the schema key, and its ID decides which files it reads.
The extension has two optional SDL files in the module’s graphql directory, named after the extension ID:
-
meridian_collection.base.graphqlsholds new types -
meridian_collection.extension.graphqlsholdsextend typeblocks that add fields to types of the schema
# graphql/meridian_collection.base.graphqls
type CollectionObject {
id: Int!
accessionNumber: String
period: String
material: String
onDisplay: Boolean!
}
# graphql/meridian_collection.extension.graphqls
extend type Query {
objectsOnDisplay(limit: Int = 10): [CollectionObject!]!
}
Then the extension class registers the resolvers for the new fields:
<?php
declare(strict_types=1);
namespace Drupal\meridian_collection\Plugin\GraphQL\SchemaExtension;
use Drupal\graphql\Attribute\SchemaExtension;
use Drupal\graphql\GraphQL\ResolverBuilder;
use Drupal\graphql\GraphQL\ResolverRegistryInterface;
use Drupal\graphql\Plugin\GraphQL\SchemaExtension\SdlSchemaExtensionPluginBase;
/**
* Adds the museum collection to the Meridian schema.
*/
#[SchemaExtension(
id: 'meridian_collection',
name: 'Meridian collection',
description: 'Adds collection objects to the Meridian schema.',
schema: 'meridian',
)]
class CollectionSchemaExtension extends SdlSchemaExtensionPluginBase {
/**
* {@inheritdoc}
*/
public function registerResolvers(ResolverRegistryInterface $registry): void {
$builder = new ResolverBuilder();
$registry->addFieldResolver('Query', 'objectsOnDisplay',
$builder->produce('meridian_objects_on_display')
->map('limit', $builder->fromArgument('limit'))
);
$registry->addFieldResolver('CollectionObject', 'id',
$builder->produce('entity_id')
->map('entity', $builder->fromParent())
);
$fields = [
'accessionNumber' => 'accession_number.value',
'period' => 'period.value',
'material' => 'material.value',
];
foreach ($fields as $field => $path) {
$registry->addFieldResolver('CollectionObject', $field,
$builder->produce('property_path')
->map('type', $builder->fromValue('entity:collection_object'))
->map('value', $builder->fromParent())
->map('path', $builder->fromValue($path))
);
}
$registry->addFieldResolver('CollectionObject', 'onDisplay',
$builder->produce('property_path')
->map('type', $builder->fromValue('entity:collection_object'))
->map('value', $builder->fromParent())
->map('path', $builder->fromValue('on_display.value'))
);
}
}
The extension module declares its dependencies in meridian_collection.info.yml (meridian_graphql:meridian_graphql,
or graphql:graphql if the module should work with any schema). Enable it and rebuild caches so that the plugin
managers discover the new plugins and the schema is rebuilt:
drush en meridian_graphql meridian_collection -y
drush config:import -y
drush cache:rebuild
Source: Drush: pm:install
The drush config:import step imports the exported server. Run the following query in the server’s Explorer tab, or
from a client:
query OnDisplay {
objectsOnDisplay(limit: 3) {
accessionNumber
period
material
}
}
The same request over HTTP, with the endpoint taken from the environment and a bearer token for a developer account:
curl --silent "$MERIDIAN_BASE_URL/graphql" \
--header 'Content-Type: application/json' \
--header "Authorization: Bearer $MERIDIAN_API_TOKEN" \
--data '{"query":"{ objectsOnDisplay(limit: 3) { accessionNumber period material } }"}'
Source: GraphQL documentation: Queries
A response has a data object shaped like the query, and an errors list when something failed.
{
"data": {
"objectsOnDisplay": [
{ "accessionNumber": "MM-1904-017", "period": "Victorian", "material": "Brass" },
{ "accessionNumber": "MM-1921-203", "period": "Edwardian", "material": "Glass" },
{ "accessionNumber": "MM-1957-009", "period": "Mid-century", "material": "Bakelite" }
]
}
}
Why the split matters
The schema module owns the root types. Each feature module adds its own slice. Three consequences follow:
-
a site can install only the slices it needs
-
a module that is uninstalled removes its types, fields and resolvers from the API together with its code
-
modules that must change a type declared elsewhere use
extend type, which keeps ownership of the original type clear
The module also dispatches an event that lets code alter the extension data of a schema before it is built, and, since
5.0, that event is available to every schema that extends SdlSchemaPluginBase. The older composable-schema class
AlterableComposableSchema (plugin ID alterable_composable) is deprecated in 5.0 because of that move.
Mutations
A mutation is a query that changes data. In the schema it is a field on a Mutation type, resolved with a producer
that performs the change, for example one that creates a node. The documentation has a section for mutations and one
for validations. The rules that matter for the museum are the same as for any write path: check the permission in the
producer (a GraphQL permission alone is not enough), validate the input with the entity validation API, and never accept
IDs or field names from the client without checking them. For most editorial writes a decoupled application is better
served by JSON:API write operations with OAuth scopes, or by a normal Drupal form.
Source: GraphQL documentation: Mutations
Caching
Server caching is on by default (caching: true). The module caches the result of a query with the cache metadata the
producers collected: tags such as node:12 for every entity loaded, cache contexts such as the user’s permissions, and
a max-age. Producers add this metadata themselves through the RefinableCacheableDependencyInterface parameter, as the
custom producer above does. The same render-cache rules as for blocks apply (see
Render API and cache metadata and
Performance and caching). A custom producer that reads data without
declaring tags, contexts or max-age gives stale answers.
Two caching limits are worth knowing. Because most clients send queries in a POST body, a CDN or reverse proxy such as
Varnish cannot cache them by URL; the cache the module offers is inside Drupal. Persisted queries let a client send a
short identifier in a GET request, which proxies can cache. And a server with batching: true accepts several
operations in one request, which is convenient for clients and makes limits more important.
Security
GraphQL gives a client more freedom than a fixed REST route, and the server must limit it.
-
Access control. Grant only the persisted-request permission to public roles. Entity producers check
viewaccess by default; keep it that way, and check access in custom producers and in the entity queries they run. -
Depth and complexity. A client can nest
relatedobjects ofrelatedobjects without end. Setquery_depthandquery_complexityon the server (the example export uses 8 and 200) and measure with real queries before tightening them. The value 0 means unlimited, which is the default. -
Introspection. Introspection lets any client list the whole schema. It is useful in development and for public APIs designed to be explored, and it can be switched off with
disable_introspection: truefor private APIs. Switching it off does not protect data; access checks do. -
Limits on lists. Cap
limitarguments in producers, as the example producer does withMAX_LIMIT. -
Errors. Keep
debug_flagat 0 in production. AUserErroris for messages meant for clients; do not put internal details in it. -
Batching. Batched requests multiply cost; consider turning batching off for a public endpoint.
-
Secrets. Tokens used by clients come from environment variables or the OAuth provider, never from the repository.
-
Exposure by design. In 4.x and 5.x only the fields in the SDL files exist, which is safer than the 3.x model in which the module generated types for everything.
See also Security hardening.
GraphQL or JSON:API
Both approaches serve a decoupled front end. They differ in who decides the shape of the data.
| Aspect | JSON:API (core) | GraphQL (contrib, 5.x) |
|---|---|---|
Availability |
Core module, no configuration needed |
Contributed module, Composer install |
API shape |
Fixed by the JSON:API specification and the site’s entities, bundles and fields |
Designed by the developer in SDL; only declared fields exist |
Effort to start |
Enable the module and request |
Write schema, resolvers and often custom producers |
Choosing fields |
Sparse fieldsets ( |
The query names every field and nested relation |
Requests per screen |
Usually few, thanks to |
One, even across several entity types |
Filtering and sorting |
Query-string |
Only the arguments the schema declares |
HTTP caching |
GET requests can be cached by URL and use cache tags |
POST bodies are not cacheable by URL; persisted queries help |
Access control |
Entity and field access, with Drupal permissions |
Entity access in producers that check it; permissions per server |
Writes |
Create, update and delete on resources, with OAuth |
Mutations written by hand |
Maintenance |
Follows the data model automatically |
The schema must be kept in step with the data model |
Choose JSON:API when the client needs content that maps closely to entities, when the team has no appetite for schema code, or when HTTP caching of GET requests matters; the museum’s kiosk (see Decoupled Drupal with Next.js) uses it. Choose GraphQL when several clients need different views of the same data, when a screen needs data from several sources in one request, when a typed contract between front end and back end is a requirement, or when the API should present a stable model that differs from the content model. GraphQL Compose reduces the effort by generating the schema, at the price of the schema following the content model again. Both can run on the same site. See also REST and serialization for the third option and Comparison and decision guide.
Common errors and pitfalls
-
Field returns
nullwith no error. The field is in the SDL file but no resolver is registered for the type and field name pair, or the names differ in case (startsOnandstartsonare different fields). -
Schema changes do not appear. The schema is cached. Run
drush cache:rebuildafter changing.graphqlsfiles or adding plugins. -
Schema extension not foundor the extension’s types are missing. The extension’sschemavalue must equal the schema plugin’s ID, the SDL file names must match the extension ID, and the module must be enabled. -
An entity query returns restricted objects. The producer’s query lacks
accessCheck(TRUE), or the access check does not look at the published status (the module’s own example adds an explicit status condition for that reason). -
Producer arguments are
null. Themap()name does not match theconsumeskey, or an input is not markedrequired: FALSE. -
N+1 queries. A custom producer loads entities inside a loop instead of returning IDs to
entity_loador using the entity buffer. -
Stale answers. A custom producer returns data without adding cache tags, contexts or max-age to the metadata.
-
Plugins are not discovered. The class is not under
src/Plugin/GraphQL/DataProducer,SchemaorSchemaExtension, or an attribute is combined with the wrong namespace (Drupal\graphql\Attribute). -
Attributes ignored on 4.x. The attribute classes arrived in 5.0; use annotations on 4.x.
-
Leftover
graphql.settings.yml. Upgrading to 5.0 removes this file; delete it from version control if you track it.
Version notes
-
GraphQL 5.0 moved webonyx/graphql-php to version 15, added parameter, return and property types throughout, allowed PHP attributes for the schema, schema extension, data producer and persisted query plugins (annotations still work), and removed
graphql.settings.yml. The release notes state that few breaking changes are visible to most sites. -
Custom code that extends the module’s classes must adapt to the new type declarations: the signatures of
ResolverRegistry::resolveField(),ResolverInterface::resolve(), and the schema plugin methodsgetSchema()andgetResolverRegistry()now include types. The list of API changes is in the module’s change records. -
GraphQL 5.x supports Drupal ^10.4 || ^11; 4.x supports ^10.2 || ^11. Drupal 10 reaches end of life on 2026-12-09, so new sites start on Drupal 11.
-
Drupal core changes that affect the code on this page: plugin attributes since 10.2,
\Drupal::calls replaced by constructor injection with autowiring, andaccessCheck()required on entity queries. -
Upgrading from 4.x to 5.x is done with Composer (
composer require 'drupal/graphql:^5.1' --with-all-dependencies), followed bydrush updatedbanddrush config:export; converting annotations to attributes is optional.
References
-
GraphQL (drupal.org project page)
-
GraphQL (drupal.org documentation)
-
Drupal GraphQL documentation (GitBook) and its pages Installation, Creating schemas, Data producers, Custom producers, Composing producers and Composable schemas
-
GraphQL module: example modules (5.x branch)
-
GraphQL Compose (drupal.org project page)
-
JSON:API module (drupal.org documentation)
-
Introduction to GraphQL (graphql.org)
-
webonyx/graphql-php (library)