
Building a Custom GraphQL API Module for Drupal 10 Article Management
In this tutorial, we'll walk through the process of creating a custom GraphQL API module for Drupal 10, focusing on article management. This module will allow you to query, create, update, and delete articles using GraphQL.
Table of Contents
- Prerequisites
- Step 1: Create the Module Structure
- Step 2: Create Module Files
- Step 3: Define Module Information
- Step 4: Create the Module File
- Step 5: Define Services
- Step 6: Create the Schema Extension
- Step 7: Create the ArticleManager Service
- Step 8: Implement Custom GraphQL Plugins
- Step 9: Implement Access Checks
- Step 10: Update the Schema Extension with Access Checks
- Step 11: Enable the Module
- Step 12: Test Your GraphQL API
- Conclusion
Prerequisites
- Drupal 10 installed
- Basic knowledge of Drupal module development
- Familiarity with GraphQL concepts
Step 1: Create the Module Structure
First, let's create the directory for our new module:
Bashmkdir -p web/modules/custom/custom_graphql_api cd web/modules/custom/custom_graphql_api
Step 2: Create Module Files
Create the following files in the custom_graphql_api
directory:
custom_graphql_api.info.yml
custom_graphql_api.module
custom_graphql_api.services.yml
Step 3: Define Module Information
Edit custom_graphql_api.info.yml
:
YAMLname: Custom GraphQL API type: module description: 'Provides a custom GraphQL API for article management.' package: Custom core_version_requirement: ^9 || ^10 dependencies: - drupal:node - graphql:graphql - next:next - entityqueue:entityqueue - paragraphs:paragraphs
Step 4: Create the Module File
Edit custom_graphql_api.module
:
PHP<?php /** * @file * Contains custom_graphql_api.module. */ use Drupal\Core\Routing\RouteMatchInterface; /** * Implements hook_help(). */ function custom_graphql_api_help($route_name, RouteMatchInterface $route_match) { switch ($route_name) { case 'help.page.custom_graphql_api': $output = ''; $output .= '<h3>' . t('About') . '</h3>'; $output .= '<p>' . t('This module provides a custom GraphQL API for article management.') . '</p>'; return $output; default: } } /** * Implements hook_theme(). */ function custom_graphql_api_theme() { return [ 'article_graphql_data' => [ 'variables' => [ 'article' => NULL, ], ], ]; }
Step 5: Define Services
Edit custom_graphql_api.services.yml
:
YAMLservices: custom_graphql_api.schema_extension: class: Drupal\custom_graphql_api\Plugin\GraphQL\SchemaExtension\CustomGraphQLApiSchemaExtension tags: - { name: graphql_schema_extension } custom_graphql_api.article_manager: class: Drupal\custom_graphql_api\ArticleManager arguments: ['@entity_type.manager', '@database']
Step 6: Create the Schema Extension
Create a new file src/Plugin/GraphQL/SchemaExtension/CustomGraphQLApiSchemaExtension.php
:
PHP<?php namespace Drupal\custom_graphql_api\Plugin\GraphQL\SchemaExtension; use Drupal\graphql\GraphQL\ResolverBuilder; use Drupal\graphql\GraphQL\ResolverRegistryInterface; use Drupal\graphql\Plugin\GraphQL\SchemaExtension\SdlSchemaExtensionPluginBase; use Drupal\custom_graphql_api\GraphQL\Access\ArticleAccessCheck; /** * @SchemaExtension( * id = "custom_graphql_api", * name = "Custom GraphQL API Schema Extension", * description = "Schema extension for the custom GraphQL API", * schema = "next" * ) */ class CustomGraphQLApiSchemaExtension extends SdlSchemaExtensionPluginBase { /** * {@inheritdoc} */ public function registerResolvers(ResolverRegistryInterface $registry) { $builder = new ResolverBuilder(); $this->addQueryFields($registry, $builder); $this->addMutationFields($registry, $builder); $this->addCustomTypes($registry, $builder); } /** * Add custom query fields. */ protected function addQueryFields(ResolverRegistryInterface $registry, ResolverBuilder $builder) { // Query to fetch an article by ID $registry->addFieldResolver('Query', 'articleById', $builder->produce('entity_load') ->map('type', $builder->fromValue('node')) ->map('bundles', $builder->fromValue(['article'])) ->map('id', $builder->fromArgument('id')) ->addExtraField('access', $builder->fromValue([ArticleAccessCheck::class, 'checkQueryAccess'])) ); // Query to fetch multiple articles $registry->addFieldResolver('Query', 'articles', $builder->produce('query_articles') ->map('limit', $builder->fromArgument('limit')) ->map('offset', $builder->fromArgument('offset')) ->addExtraField('access', $builder->fromValue([ArticleAccessCheck::class, 'checkQueryAccess'])) ); } /** * Add custom mutation fields. */ protected function addMutationFields(ResolverRegistryInterface $registry, ResolverBuilder $builder) { // Mutation to create a new article $registry->addFieldResolver('Mutation', 'createArticle', $builder->produce('create_article') ->map('input', $builder->fromArgument('input')) ->addExtraField('access', $builder->fromValue([ArticleAccessCheck::class, 'checkCreateAccess'])) ); // Mutation to update an existing article $registry->addFieldResolver('Mutation', 'updateArticle', $builder->produce('update_article') ->map('id', $builder->fromArgument('id')) ->map('input', $builder->fromArgument('input')) ->addExtraField('access', $builder->fromValue([ArticleAccessCheck::class, 'checkUpdateAccess'])) ); // Mutation to delete an article $registry->addFieldResolver('Mutation', 'deleteArticle', $builder->produce('delete_article') ->map('id', $builder->fromArgument('id')) ->addExtraField('access', $builder->fromValue([ArticleAccessCheck::class, 'checkDeleteAccess'])) ); } /** * Add custom types. */ protected function addCustomTypes(ResolverRegistryInterface $registry, ResolverBuilder $builder) { // Resolvers for the Article type $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.value')) ); $registry->addFieldResolver('Article', 'created', $builder->produce('entity_created') ->map('entity', $builder->fromParent()) ); $registry->addFieldResolver('Article', 'author', $builder->compose( $builder->produce('entity_owner') ->map('entity', $builder->fromParent()), $builder->produce('entity_label') ->map('entity', $builder->fromParent()) ) ); } /** * {@inheritdoc} */ public function getSchema() { return <<<GQL extend type Query { articleById(id: ID!): Article articles(limit: Int = 10, offset: Int = 0): [Article!]! } extend type Mutation { createArticle(input: CreateArticleInput!): Article updateArticle(id: ID!, input: UpdateArticleInput!): Article deleteArticle(id: ID!): Boolean } type Article implements NodeInterface { id: ID! title: String! body: String created: DateTime! author: String } input CreateArticleInput { title: String! body: String } input UpdateArticleInput { title: String body: String } GQL; } }
Step 7: Create the ArticleManager Service
Create a new file src/ArticleManager.php
:
PHP<?php namespace Drupal\custom_graphql_api; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Database\Connection; use Drupal\node\NodeInterface; /** * Service for managing articles. */ class ArticleManager { /** * The entity type manager. * * @var \Drupal\Core\Entity\EntityTypeManagerInterface */ protected $entityTypeManager; /** * The database connection. * * @var \Drupal\Core\Database\Connection */ protected $database; /** * Constructs a new ArticleManager object. * * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. * @param \Drupal\Core\Database\Connection $database * The database connection. */ public function __construct(EntityTypeManagerInterface $entity_type_manager, Connection $database) { $this->entityTypeManager = $entity_type_manager; $this->database = $database; } /** * Retrieves a list of articles. * * @param int $limit * The number of articles to retrieve. * @param int $offset * The number of articles to skip. * * @return \Drupal\node\NodeInterface[] * An array of article entities. */ public function getArticles($limit = 10, $offset = 0) { $query = $this->entityTypeManager->getStorage('node')->getQuery() ->condition('type', 'article') ->condition('status', NodeInterface::PUBLISHED) ->sort('created', 'DESC') ->range($offset, $limit) ->accessCheck(TRUE); $nids = $query->execute(); return $this->entityTypeManager->getStorage('node')->loadMultiple($nids); } /** * Creates a new article. * * @param array $values * An array of values to set on the new article. * * @return \Drupal\node\NodeInterface * The newly created article entity. * * @throws \Drupal\Core\Entity\EntityStorageException */ public function createArticle(array $values) { $article = $this->entityTypeManager->getStorage('node')->create([ 'type' => 'article', 'title' => $values['title'], 'body' => [ 'value' => $values['body'], 'format' => 'basic_html', ], 'status' => NodeInterface::PUBLISHED, ]); $article->save(); return $article; } /** * Updates an existing article. * * @param int $id * The ID of the article to update. * @param array $values * An array of values to update on the article. * * @return \Drupal\node\NodeInterface|null * The updated article entity, or null if not found. * * @throws \Drupal\Core\Entity\EntityStorageException */ public function updateArticle($id, array $values) { $article = $this->entityTypeManager->getStorage('node')->load($id); if (!$article || $article->bundle() !== 'article') { return null; } if (isset($values['title'])) { $article->setTitle($values['title']); } if (isset($values['body'])) { $article->set('body', [ 'value' => $values['body'], 'format' => 'basic_html', ]); } $article->save(); return $article; } /** * Deletes an article. * * @param int $id * The ID of the article to delete. * * @return bool * TRUE if the article was successfully deleted, FALSE otherwise. * * @throws \Drupal\Core\Entity\EntityStorageException */ public function deleteArticle($id) { $article = $this->entityTypeManager->getStorage('node')->load($id); if (!$article || $article->bundle() !== 'article') { return false; } $article->delete(); return true; } }
The implementation of the ArticleManager
class provides methods for managing articles:
getArticles($limit = 10, $offset = 0)
:
- Uses EntityQuery to fetch published articles.
- Sorts articles by creation date in descending order.
- Implements pagination using
$limit
and$offset
. - Performs an access check to ensure the current user has permission to view the articles.
createArticle(array $values)
:
- Creates a new article node with the provided title and body.
- Sets the article status to published.
- Saves the new article and returns the entity.
updateArticle($id, array $values)
:
- Loads the article by ID and checks if it exists and is of type 'article'.
- Updates the title and/or body if provided in the $values array.
- Saves the updated article and returns the entity.
deleteArticle($id)
:
- Loads the article by ID and checks if it exists and is of type 'article'.
- Deletes the article and returns a boolean indicating success or failure. Each method includes proper error handling and type hinting for better code quality and debugging. The class also uses dependency injection for the EntityTypeManager and database Connection, allowing for easier testing and maintenance.
Step 8: Implement Custom GraphQL Plugins
Create the following files in the src/Plugin/GraphQL/DataProducer
directory:
QueryArticles.php
CreateArticle.php
UpdateArticle.php
DeleteArticle.php
Each file should contain a class that extendsDataProducerPluginBase
and implements the necessary logic for querying, creating, updating, and deleting articles.
PHP<?php namespace Drupal\custom_graphql_api\Plugin\GraphQL\DataProducer; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; use Drupal\custom_graphql_api\ArticleManager; use Symfony\Component\DependencyInjection\ContainerInterface; /** * Creates a new article. * * @DataProducer( * id = "create_article", * name = @Translation("Create Article"), * description = @Translation("Creates a new article."), * produces = @ContextDefinition("any", * label = @Translation("Article") * ), * consumes = { * "input" = @ContextDefinition("any", * label = @Translation("Article input") * ) * } * ) */ class CreateArticle extends DataProducerPluginBase implements ContainerFactoryPluginInterface { /** * The article manager service. * * @var \Drupal\custom_graphql_api\ArticleManager */ protected $articleManager; /** * {@inheritdoc} */ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { return new static( $configuration, $plugin_id, $plugin_definition, $container->get('custom_graphql_api.article_manager') ); } /** * CreateArticle constructor. * * @param array $configuration * A configuration array containing information about the plugin instance. * @param string $plugin_id * The plugin_id for the plugin instance. * @param mixed $plugin_definition * The plugin implementation definition. * @param \Drupal\custom_graphql_api\ArticleManager $article_manager * The article manager service. */ public function __construct(array $configuration, $plugin_id, $plugin_definition, ArticleManager $article_manager) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->articleManager = $article_manager; } /** * Creates a new article. * * @param array $input * The input data for creating the article. * * @return \Drupal\node\NodeInterface * The newly created article entity. */ public function resolve(array $input) { return $this->articleManager->createArticle($input); } }
UpdateArticle.php
:
PHP<?php namespace Drupal\custom_graphql_api\Plugin\GraphQL\DataProducer; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; use Drupal\custom_graphql_api\ArticleManager; use Symfony\Component\DependencyInjection\ContainerInterface; /** * Updates an existing article. * * @DataProducer( * id = "update_article", * name = @Translation("Update Article"), * description = @Translation("Updates an existing article."), * produces = @ContextDefinition("any", * label = @Translation("Article") * ), * consumes = { * "id" = @ContextDefinition("string", * label = @Translation("Article ID") * ), * "input" = @ContextDefinition("any", * label = @Translation("Article input") * ) * } * ) */ class UpdateArticle extends DataProducerPluginBase implements ContainerFactoryPluginInterface { /** * The article manager service. * * @var \Drupal\custom_graphql_api\ArticleManager */ protected $articleManager; /** * {@inheritdoc} */ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { return new static( $configuration, $plugin_id, $plugin_definition, $container->get('custom_graphql_api.article_manager') ); } /** * UpdateArticle constructor. * * @param array $configuration * A configuration array containing information about the plugin instance. * @param string $plugin_id * The plugin_id for the plugin instance. * @param mixed $plugin_definition * The plugin implementation definition. * @param \Drupal\custom_graphql_api\ArticleManager $article_manager * The article manager service. */ public function __construct(array $configuration, $plugin_id, $plugin_definition, ArticleManager $article_manager) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->articleManager = $article_manager; } /** * Updates an existing article. * * @param string $id * The ID of the article to update. * @param array $input * The input data for updating the article. * * @return \Drupal\node\NodeInterface|null * The updated article entity, or null if not found. */ public function resolve($id, array $input) { return $this->articleManager->updateArticle($id, $input); } }
DeleteArticle.php
:
PHP<?php namespace Drupal\custom_graphql_api\Plugin\GraphQL\DataProducer; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; use Drupal\graphql\Plugin\GraphQL\DataProducer\DataProducerPluginBase; use Drupal\custom_graphql_api\ArticleManager; use Symfony\Component\DependencyInjection\ContainerInterface; /** * Deletes an existing article. * * @DataProducer( * id = "delete_article", * name = @Translation("Delete Article"), * description = @Translation("Deletes an existing article."), * produces = @ContextDefinition("boolean", * label = @Translation("Deletion status") * ), * consumes = { * "id" = @ContextDefinition("string", * label = @Translation("Article ID") * ) * } * ) */ class DeleteArticle extends DataProducerPluginBase implements ContainerFactoryPluginInterface { /** * The article manager service. * * @var \Drupal\custom_graphql_api\ArticleManager */ protected $articleManager; /** * {@inheritdoc} */ public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { return new static( $configuration, $plugin_id, $plugin_definition, $container->get('custom_graphql_api.article_manager') ); } /** * DeleteArticle constructor. * * @param array $configuration * A configuration array containing information about the plugin instance. * @param string $plugin_id * The plugin_id for the plugin instance. * @param mixed $plugin_definition * The plugin implementation definition. * @param \Drupal\custom_graphql_api\ArticleManager $article_manager * The article manager service. */ public function __construct(array $configuration, $plugin_id, $plugin_definition, ArticleManager $article_manager) { parent::__construct($configuration, $plugin_id, $plugin_definition); $this->articleManager = $article_manager; } /** * Deletes an existing article. * * @param string $id * The ID of the article to delete. * * @return bool * True if the article was successfully deleted, false otherwise. */ public function resolve($id) { return $this->articleManager->deleteArticle($id); } } These custom GraphQL plugins implement the core functionality for creating, updating, and deleting articles. They use the `ArticleManager` service to perform the actual operations on the article entities. ## Step 9: Implement Access Checks Create a new file `src/GraphQL/Access/ArticleAccessCheck.php`: <?php namespace Drupal\custom_graphql_api\GraphQL\Access; use Drupal\Core\Session\AccountInterface; use Drupal\graphql\GraphQL\Execution\FieldContext; /** * Provides access checks for article-related GraphQL fields. */ class ArticleAccessCheck { public static function checkQueryAccess(AccountInterface $account, FieldContext $field_context) { return $account->hasPermission('access content'); } // Other access check methods... } ## Step 10: Update the Schema Extension with Access Checks Modify the `CustomGraphQLApiSchemaExtension.php` file to include access checks for each resolver. ## Step 11: Enable the Module Enable your custom module using Drush or the Drupal admin interface: ```bash drush en custom_graphql_api
Step 12: Test Your GraphQL API
You can now test your custom GraphQL API using GraphQL IDE or by making requests to the GraphQL endpoint. Here are some example queries and mutations:
Query an article by ID:
GRAPHQLquery { articleById(id: "1") { id title body created author { name email } } }
Query multiple articles:
GRAPHQLquery { articles(limit: 5, offset: 0) { id title body created } }
Create a new article:
GRAPHQLmutation { createArticle(input: { title: "New Article Title" body: "This is the content of the new article." }) { id title body } }
Update an existing article:
GRAPHQLmutation { updateArticle(id: "1", input: { title: "Updated Article Title" body: "This is the updated content of the article." }) { id title body } }
Delete an article:
GRAPHQLmutation { deleteArticle(id: "1") }
Conclusion
This tutorial has walked you through the process of creating a custom GraphQL API module for Drupal 10, focused on article management. The module provides a solid foundation for querying, creating, updating, and deleting articles through a GraphQL API. You can further extend this module by adding more fields, implementing pagination, or integrating with other Drupal features like taxonomies and media entities. Remember to implement proper error handling, input validation, and security measures in a production environment. Also, consider implementing caching strategies to improve the performance of your GraphQL API. Happy coding!
Related Posts

Next.js Performance Optimization: The Complete 2025 Guide
Master advanced Next.js 15+ performance techniques including React Server Components, streaming, and Core Web Vitals optimization for lightning-fast applications.

Web Performance Metrics That Actually Matter in 2025
Navigate the evolving landscape of web performance with Core Web Vitals updates, INP transition, and business-critical metrics that drive real results.

Tutorial: Migrating a Large-Scale Legacy Drupal Site to Headless Next.js
This tutorial focuses on implementing a hybrid approach to migrate a large-scale Drupal site to a headless architecture using Next.js.