Developing Custom Drupal Modules
A custom module is a way to add behavior to Drupal that doesn't exist in contrib. Architecturally, Drupal 10 is a Symfony application: services in a DI container, event subscribers, plugins, annotations. Understanding this architecture is mandatory — without it you get a pile of hooks without structure.
Module Structure
web/modules/custom/my_module/
├── my_module.info.yml
├── my_module.module # procedural hooks (minimal)
├── my_module.install # install/update/uninstall hooks
├── my_module.routing.yml # routes
├── my_module.services.yml # DI services
├── my_module.links.menu.yml # menu items
├── my_module.permissions.yml # permissions
├── config/
│ ├── install/ # config created on install
│ └── schema/ # config schemas for validation
├── src/
│ ├── Controller/
│ │ └── ArticleController.php
│ ├── Form/
│ │ └── SettingsForm.php
│ ├── Plugin/
│ │ ├── Block/
│ │ │ └── RecentPostsBlock.php
│ │ └── Field/
│ │ ├── FieldFormatter/
│ │ └── FieldWidget/
│ ├── EventSubscriber/
│ │ └── RequestSubscriber.php
│ ├── Service/
│ │ └── ArticleService.php
│ └── Entity/
│ └── CustomEntity.php
└── templates/
└── my-module-template.html.twig
my_module.info.yml
name: 'My Module'
type: module
description: 'Custom project functionality'
core_version_requirement: ^10
package: Custom
dependencies:
- drupal:node
- drupal:user
- drupal:views
Controller and Route
# my_module.routing.yml
my_module.article_list:
path: '/articles'
defaults:
_controller: '\Drupal\my_module\Controller\ArticleController::list'
_title: 'Articles'
requirements:
_permission: 'access content'
my_module.article_api:
path: '/api/articles'
defaults:
_controller: '\Drupal\my_module\Controller\ArticleController::apiList'
requirements:
_permission: 'access content'
options:
_auth: ['basic_auth', 'cookie']
// src/Controller/ArticleController.php
namespace Drupal\my_module\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
class ArticleController extends ControllerBase {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}
public static function create(ContainerInterface $container): static {
return new static(
$container->get('entity_type.manager'),
);
}
public function list(): array {
$storage = $this->entityTypeManager->getStorage('node');
$ids = $storage->getQuery()
->condition('type', 'article')
->condition('status', 1)
->sort('created', 'DESC')
->range(0, 20)
->accessCheck(TRUE)
->execute();
$nodes = $storage->loadMultiple($ids);
$view_builder = $this->entityTypeManager->getViewBuilder('node');
return [
'#theme' => 'item_list',
'#items' => array_map(
fn($node) => $view_builder->view($node, 'teaser'),
$nodes
),
];
}
public function apiList(Request $request): JsonResponse {
$page = (int) $request->query->get('page', 0);
$limit = min((int) $request->query->get('limit', 10), 100);
$storage = $this->entityTypeManager->getStorage('node');
$query = $storage->getQuery()
->condition('type', 'article')
->condition('status', 1)
->sort('created', 'DESC')
->range($page * $limit, $limit)
->accessCheck(TRUE);
$ids = $query->execute();
$nodes = $storage->loadMultiple($ids);
$data = array_map(function ($node) {
return [
'id' => $node->id(),
'uuid' => $node->uuid(),
'title' => $node->getTitle(),
'created' => $node->getCreatedTime(),
'url' => $node->toUrl()->setAbsolute()->toString(),
'summary' => $node->get('body')->summary,
];
}, $nodes);
return new JsonResponse([
'data' => array_values($data),
'meta' => ['page' => $page, 'limit' => $limit],
]);
}
}
Service and DI
# my_module.services.yml
services:
my_module.article_service:
class: Drupal\my_module\Service\ArticleService
arguments:
- '@entity_type.manager'
- '@cache.default'
- '@logger.factory'
// src/Service/ArticleService.php
namespace Drupal\my_module\Service;
use Drupal\Core\Cache\CacheBackendInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
class ArticleService {
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
private readonly CacheBackendInterface $cache,
private readonly LoggerChannelFactoryInterface $loggerFactory,
) {}
public function getFeaturedArticles(int $limit = 5): array {
$cid = "my_module:featured:{$limit}";
if ($cached = $this->cache->get($cid)) {
return $cached->data;
}
$ids = $this->entityTypeManager->getStorage('node')
->getQuery()
->condition('type', 'article')
->condition('status', 1)
->condition('field_featured', 1)
->sort('created', 'DESC')
->range(0, $limit)
->accessCheck(FALSE)
->execute();
$articles = $this->entityTypeManager->getStorage('node')->loadMultiple($ids);
$data = array_values($articles);
// Cache with tags — auto-invalidated on node changes
$tags = array_map(fn($node) => "node:{$node->id()}", $articles);
$tags[] = 'node_list';
$this->cache->set($cid, $data, CacheBackendInterface::CACHE_PERMANENT, $tags);
return $data;
}
}
Block Plugin
// src/Plugin/Block/RecentPostsBlock.php
namespace Drupal\my_module\Plugin\Block;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Block\BlockPluginInterface;
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* @Block(
* id = "my_module_recent_posts",
* admin_label = @Translation("Recent Posts"),
* category = @Translation("My Module"),
* )
*/
class RecentPostsBlock extends BlockBase implements ContainerFactoryPluginInterface {
public function __construct(
array $configuration,
$plugin_id,
$plugin_definition,
private readonly ArticleService $articleService,
) {
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('my_module.article_service'),
);
}
public function build(): array {
$articles = $this->articleService->getFeaturedArticles(
$this->configuration['count'] ?? 5
);
return [
'#theme' => 'my_module_recent_posts',
'#articles' => $articles,
'#cache' => [
'tags' => ['node_list:article'],
'contexts' => ['languages'],
'max-age' => 3600,
],
];
}
public function blockForm($form, FormStateInterface $form_state): array {
$form = parent::blockForm($form, $form_state);
$form['count'] = [
'#type' => 'number',
'#title' => $this->t('Number of posts'),
'#default_value' => $this->configuration['count'] ?? 5,
'#min' => 1,
'#max' => 20,
];
return $form;
}
}
Event Subscriber
// src/EventSubscriber/RequestSubscriber.php
namespace Drupal\my_module\EventSubscriber;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
class RequestSubscriber implements EventSubscriberInterface {
public static function getSubscribedEvents(): array {
return [
KernelEvents::REQUEST => ['onRequest', 100],
];
}
public function onRequest(RequestEvent $event): void {
$request = $event->getRequest();
// Logic for every incoming request
if ($request->headers->get('X-Api-Version') === 'v2') {
$request->attributes->set('api_version', 2);
}
}
}
# my_module.services.yml — add
my_module.request_subscriber:
class: Drupal\my_module\EventSubscriber\RequestSubscriber
tags:
- { name: event_subscriber }
Hooks in .module File
Keep hooks only where there are no alternatives via plugins or services:
// my_module.module
/**
* Implements hook_node_presave().
*/
function my_module_node_presave(\Drupal\node\NodeInterface $node): void {
if ($node->bundle() === 'article') {
// Auto-calculate reading time
$body = $node->get('body')->value ?? '';
$words = str_word_count(strip_tags($body));
$node->set('field_reading_time', (int) ceil($words / 200));
}
}
/**
* Implements hook_theme().
*/
function my_module_theme(): array {
return [
'my_module_recent_posts' => [
'variables' => ['articles' => []],
'template' => 'my-module-recent-posts',
],
];
}
Install and Update Schema
// my_module.install
function my_module_install(): void {
// Create initial data
\Drupal::configFactory()
->getEditable('my_module.settings')
->set('api_key', '')
->set('cache_ttl', 3600)
->save();
}
function my_module_update_10001(): void {
// Add new field to existing content type
$field_storage = \Drupal\field\Entity\FieldStorageConfig::create([
'field_name' => 'field_reading_time',
'entity_type' => 'node',
'type' => 'integer',
]);
$field_storage->save();
\Drupal\field\Entity\FieldConfig::create([
'field_storage' => $field_storage,
'bundle' => 'article',
'label' => 'Reading time (min)',
])->save();
}
Timelines
Simple module (controller + block + hooks): 2–3 days. Module with custom entities, plugins, REST API, caching: 8–15 days depending on complexity.







