The drupal/graphql module v4 provides a schema-first GraphQL API for Drupal 10 and 11. Unlike the JSON:API module, which exposes your entire entity structure automatically, GraphQL v4 requires you to define your schema explicitly. That extra work pays off: you get a clean API surface that reflects your application's domain, not Drupal's internal data model.
Installing the Module
composer require drupal/graphql:^4
drush en graphql -y
The module requires PHP 8.1+ and Drupal 10.1+. After enabling it, visit /admin/config/graphql to see the server configuration interface. You will create a GraphQL server that binds your schema plugin to an endpoint.
The Schema-First Approach
GraphQL v4 uses a schema-first approach: you write your GraphQL schema in SDL (Schema Definition Language) as a .graphqls file, then write PHP resolvers that fulfil the schema. The module does not introspect your Drupal content types and auto-generate a schema (that was the v3 approach, which was discontinued).
This is the right approach for a production API. Your schema is a contract — it defines exactly what clients can query, independent of how Drupal happens to store the data.
Creating a Schema Plugin
Create a custom module — for this example, mymodule_graphql. The directory structure:
mymodule_graphql/
mymodule_graphql.info.yml
mymodule_graphql.services.yml
src/
Plugin/GraphQL/SchemaExtension/ArticlesSchemaExtension.php
Plugin/GraphQL/Schema/ArticlesSchema.php
graphql/
articles.graphqls
The Schema Plugin Class
<?php
namespace Drupal\mymodule_graphql\Plugin\GraphQL\Schema;
use Drupal\graphql\Plugin\GraphQL\Schema\SdlSchemaPluginBase;
use Drupal\graphql\GraphQL\ResolverRegistry;
use Drupal\graphql\GraphQL\ResolverBuilder;
/**
* @Schema(
* id = "articles",
* name = "Articles Schema",
* description = "GraphQL schema for article content"
* )
*
* In Drupal 11 with graphql ^4.6+, you can also use the PHP attribute form:
* #[Schema(id: "articles", name: "Articles Schema")]
*/
class ArticlesSchema extends SdlSchemaPluginBase {
public function getResolverRegistry(): ResolverRegistry {
$builder = new ResolverBuilder();
$registry = new ResolverRegistry();
$this->addQueryFields($registry, $builder);
$this->addArticleFields($registry, $builder);
return $registry;
}
private function addQueryFields(ResolverRegistry $registry, ResolverBuilder $builder): void {
$registry->addFieldResolver('Query', 'article',
$builder->produce('entity_load')
->map('type', $builder->fromValue('node'))
->map('id', $builder->fromArgument('id'))
);
// 'query_nodes' is the custom DataProducer plugin defined below (id = "query_nodes").
// Drupal discovers it automatically via the plugin system — no explicit import needed.
$registry->addFieldResolver('Query', 'articles',
$builder->produce('entity_load_multiple')
->map('type', $builder->fromValue('node'))
->map('ids', $builder->produce('query_nodes')
->map('type', $builder->fromValue('article'))
->map('offset', $builder->fromArgument('offset'))
->map('limit', $builder->fromArgument('limit'))
)
);
}
private function addArticleFields(ResolverRegistry $registry, ResolverBuilder $builder): void {
$registry->addFieldResolver('Article', 'id',
$builder->produce('entity_id')
->map('entity', $builder->fromParent())
);
$registry->addFieldResolver('Article', 'title',
$builder->produce('entity_label')
->map('entity', $builder->fromParent())
);
$registry->addFieldResolver('Article', 'body',
$builder->produce('property_path')
->map('type', $builder->fromValue('entity:node'))
->map('value', $builder->fromParent())
->map('path', $builder->fromValue('body.processed'))
);
$registry->addFieldResolver('Article', 'created',
$builder->produce('entity_created')
->map('entity', $builder->fromParent())
->map('format', $builder->fromValue('Y-m-d\TH:i:sP'))
);
}
}
The SDL Schema File
Place this in graphql/articles.graphqls:
type Query {
article(id: ID!): Article
articles(limit: Int = 10, offset: Int = 0): [Article!]!
}
type Article {
id: ID!
title: String!
body: String
created: String!
author: User
tags: [Term!]!
}
type User {
id: ID!
name: String!
email: String
}
type Term {
id: ID!
name: String!
}
Custom Data Producers
The ResolverBuilder uses data producers — plugins that extract data from Drupal entities. For cases not covered by built-in producers, write your own:
<?php
namespace Drupal\mymodule_graphql\Plugin\GraphQL\DataProducer;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Loads a list of article node IDs via entity query.
*
* @DataProducer(
* id = "query_nodes",
* name = "Query nodes",
* description = "Returns a list of node IDs",
* produces = @ContextDefinition("any", label = "Node IDs"),
* consumes = {
* "type" = @ContextDefinition("string", label = "Bundle"),
* "offset" = @ContextDefinition("integer", label = "Offset", required = false),
* "limit" = @ContextDefinition("integer", label = "Limit", required = false)
* }
* )
*/
class QueryNodes extends DataProducerPluginBase implements ContainerFactoryPluginInterface {
public function __construct(
array $configuration,
string $plugin_id,
mixed $plugin_definition,
private readonly EntityTypeManagerInterface $entityTypeManager,
) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
}
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
return new static(
$configuration,
$plugin_id,
$plugin_definition,
$container->get('entity_type.manager'),
);
}
public function resolve(string $type, int $offset = 0, int $limit = 10): array {
$storage = $this->entityTypeManager->getStorage('node');
return $storage->getQuery()
->accessCheck(TRUE)
->condition('type', $type)
->condition('status', 1)
->sort('created', 'DESC')
->range($offset, $limit)
->execute();
}
}
Handling Entity References
To resolve an entity reference field (e.g., the article's author or tags), add resolvers for those fields:
// Resolve the author (user entity reference)
$registry->addFieldResolver('Article', 'author',
$builder->produce('entity_owner')
->map('entity', $builder->fromParent())
);
$registry->addFieldResolver('User', 'id',
$builder->produce('entity_id')
->map('entity', $builder->fromParent())
);
$registry->addFieldResolver('User', 'name',
$builder->produce('entity_label')
->map('entity', $builder->fromParent())
);
// Resolve taxonomy term references (tags field)
$registry->addFieldResolver('Article', 'tags',
$builder->produce('entity_reference')
->map('entity', $builder->fromParent())
->map('field', $builder->fromValue('field_tags'))
);
$registry->addFieldResolver('Term', 'id',
$builder->produce('entity_id')
->map('entity', $builder->fromParent())
);
$registry->addFieldResolver('Term', 'name',
$builder->produce('entity_label')
->map('entity', $builder->fromParent())
);
Authentication
Session Cookies
By default, GraphQL queries inherit Drupal's current user context. If the client sends a valid session cookie (from a user.login REST call or regular browser session), queries will respect that user's access permissions. This works for same-site JavaScript clients.
OAuth2 via Simple OAuth
For decoupled clients, use the simple_oauth module:
composer require drupal/simple_oauth
drush en simple_oauth -y
Configure a client at /admin/config/people/simple_oauth. Clients obtain a Bearer token via the OAuth2 token endpoint, then include it in GraphQL requests:
curl -X POST https://example.com/graphql \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "{ articles { id title } }"}'
The GraphQL module respects Drupal's access control: entity_load producers call accessCheck(TRUE), so unpublished content is not returned to anonymous users regardless of what the query requests.
Using GraphiQL
The GraphQL module ships with GraphiQL, an in-browser IDE for exploring and testing your schema. Visit /graphql/explorer (the exact path depends on your server configuration). You can browse the schema documentation, write queries with autocomplete, and see results in real time.
Enable explorer access at /admin/config/graphql — it is off by default for security. On production, only enable it for authenticated users with the appropriate permission.
Caching Considerations
GraphQL responses in Drupal must carry proper cache metadata for Drupal's cache system to work correctly. When using the built-in data producers, cache metadata is handled automatically — the response will be tagged with the entities it resolves.
In custom data producers, you must add cache metadata manually:
use Drupal\graphql\GraphQL\Execution\FieldContext;
public function resolve(string $type, int $offset, int $limit, FieldContext $context): array {
$ids = $this->entityTypeManager->getStorage('node')
->getQuery()
->accessCheck(TRUE)
->condition('type', $type)
->condition('status', 1)
->sort('created', 'DESC')
->range($offset, $limit)
->execute();
// Tell the cache system this result depends on the node list
$context->addCacheTags(['node_list']);
$context->addCacheContexts(['user.permissions']);
return $ids;
}
Without proper cache metadata, you risk serving stale data to the wrong users. The FieldContext parameter is automatically injected into resolver methods if declared as the last parameter.
A Complete Example Query
query GetArticles {
articles(limit: 5, offset: 0) {
id
title
body
created
author {
name
}
tags {
name
}
}
}
And via curl:
curl -X POST https://example.com/graphql \
-H "Content-Type: application/json" \
-d '{
"query": "{ articles(limit: 5) { id title created author { name } } }"
}'
Configuring the GraphQL Server
Go to /admin/config/graphql, click "Add Server", and configure:
- Label: Articles API
- Schema: select your
articlesschema plugin - Endpoint:
/graphql - Query caching: enable on production
The endpoint can be secured further via Drupal's route access control — add a permission check to the route or use Nginx to restrict access by IP for internal APIs.
The schema-first approach of GraphQL v4 requires more upfront work than JSON:API's zero-configuration model, but the result is a purpose-built API that exposes exactly what your clients need. Combined with Drupal's entity access system and the FieldContext caching API, it is a solid foundation for a headless Drupal architecture.