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

flowchart LR C[Client application] -->|POST /graphql with query| S[Server: graphql_servers.meridian] S --> P{Permission check:<br/>arbitrary or persisted} P -->|allowed| V[Validation:<br/>depth, complexity, introspection] V --> X[Executor walks the query] X --> R[Resolver registry:<br/>type + field] R --> D1[entity_load producer] R --> D2[property_path producer] R --> D3[custom producer] D1 --> E[(Entities and access checks)] D2 --> E D3 --> E X --> J[JSON response with<br/>cache metadata]

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 configuration creates, edits and deletes servers (restricted: give it to trusted administrators only)

  • bypass graphql access lets a user use any server regardless of its permission restrictions (restricted)

  • execute meridian arbitrary graphql requests lets a role send any query to the meridian server

  • execute meridian persisted graphql requests lets 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!]
}

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, and fromParent() supplies the value the parent field resolved to. For Exhibition.title the parent is the node that Query.exhibition loaded.

  • fromValue(['exhibition']) restricts the load to one bundle, so asking for the ID of a news item returns null instead of leaking another content type.

  • property_path reads 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

entity_load

Loads one entity by type and ID, optionally restricted to bundles and a language, with an access check (the default checks the view operation for the current user)

entity_load_by_uuid, entity_load_multiple

Load by UUID or by several IDs

entity_id, entity_uuid, entity_label, entity_bundle, entity_language

Read basic properties of an entity

entity_published, entity_created, entity_changed, entity_owner, entity_url

Read status, timestamps, the owner and the URL

entity_query

Runs an entity query with limit, offset, sorts and filtering conditions

entity_rendered

Renders an entity in a view mode, for fields that must return HTML

property_path

Reads a Typed Data property path from a value

entity_reference

Follows an entity reference field

route_load, route_entity

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 id of the #[DataProducer] attribute is the name used in $builder->produce(). The class goes in src/Plugin/GraphQL/DataProducer of the module, the directory the plugin manager scans.

  • The names in consumes are the names you map() and the parameter names of resolve(). Parameters typed RefinableCacheableDependencyInterface receive the cache metadata object, and a FieldContext parameter receives the field’s context.

  • A limit is enforced and a UserError is thrown when it is exceeded. A UserError is 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’s view access) still need an access check on load, or a filter in the query itself.

  • The cache tag collection_object_list is 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 @DataProducer annotation.

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.graphqls holds new types

  • meridian_collection.extension.graphqls holds extend type blocks 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

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 } }"}'

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

flowchart TB subgraph meridian_graphql S[Schema plugin: meridian<br/>meridian.graphqls] end subgraph meridian_collection E[SchemaExtension: meridian_collection<br/>base.graphqls + extension.graphqls] P[Data producer:<br/>meridian_objects_on_display] end E -->|schema: meridian| S E --> P S --> A[Server meridian at /graphql]

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.

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 view access 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 related objects of related objects without end. Set query_depth and query_complexity on 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: true for private APIs. Switching it off does not protect data; access checks do.

  • Limits on lists. Cap limit arguments in producers, as the example producer does with MAX_LIMIT.

  • Errors. Keep debug_flag at 0 in production. A UserError is 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 /jsonapi/node/exhibition

Write schema, resolvers and often custom producers

Choosing fields

Sparse fieldsets (fields[node--exhibition]=title) and include for relationships

The query names every field and nested relation

Requests per screen

Usually few, thanks to include

One, even across several entity types

Filtering and sorting

Query-string filter, sort and page parameters on any field

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 null with 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 (startsOn and startson are different fields).

  • Schema changes do not appear. The schema is cached. Run drush cache:rebuild after changing .graphqls files or adding plugins.

  • Schema extension not found or the extension’s types are missing. The extension’s schema value 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. The map() name does not match the consumes key, or an input is not marked required: FALSE.

  • N+1 queries. A custom producer loads entities inside a loop instead of returning IDs to entity_load or 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, Schema or SchemaExtension, 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 methods getSchema() and getResolverRegistry() 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, and accessCheck() 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 by drush updatedb and drush config:export; converting annotations to attributes is optional.