Свой модуль в Drupal: рабочие приёмы без хрупкого кода
В Drupal 10 и 11 модуль быстро превращается в источник ошибок, если логику держать в одном хуке или контроллере. Разбираем, когда выбирать хуки, сервисы, плагины и подписчиков событий, и что проверять перед релизом.

В Drupal 10 и 11 простой модуль с одним маршрутом легко сделать за час, а затем месяцами исправлять побочные эффекты: бизнес-логика оказывается в хуке, доступ проверяется только в интерфейсе, а персональный ответ попадает в общий кеш. Рабочий модуль начинается не с количества файлов, а с правильной границы ответственности. Ниже — приёмы для модулей, которые нужно поддерживать, тестировать и переносить между проектами.
Сначала разделите задачу по точке расширения
Drupal даёт несколько механизмов для одной и той же цели. Но они не равноценны. Хук хорошо подходит для реакции на конкретное событие ядра, сервис — для повторно используемой логики, плагин — для набора взаимозаменяемых реализаций, а подписчик события — для сквозной реакции на событие Symfony или Drupal.
| Подход | Когда выбирать | Цена решения |
|---|---|---|
| Хук | Нужно изменить данные или поведение в известной точке Drupal | Связь с конкретным хуком; сложнее переиспользовать и тестировать |
| Сервис | Логика вызывается из контроллера, формы, очереди и тестов | Потребуется описать зависимость в контейнере |
| Плагин | Администратор или разработчик должен выбирать одну из реализаций | Больше кода: аннотация или атрибут, менеджер, конфигурация |
| Подписчик событий | Нужно реагировать на событие без изменения вызывающего кода | Порядок подписчиков и побочные эффекты не всегда очевидны |
Если логика нужна в двух разных местах, ей уже тесно внутри хука или контроллера. Вынесите её в сервис и оставьте точке расширения только подготовку входных данных.
Сноска. Dependency Injection, или внедрение зависимостей, — способ передать сервис объекту через конструктор, а не искать его внутри метода через глобальный контейнер. Так код проще заменить в тесте и труднее случайно связать с конкретной реализацией Drupal.
Приём 1. Оставляйте хуку только реакцию на событие
Хук уместен, когда Drupal уже предоставляет нужную точку: сохранение сущности, изменение формы, построение ссылок или изменение ответа. Не превращайте файл MODULE.module в склад всей логики сайта.
Например, модулю нужно добавить метку к узлу перед сохранением. Сам хук получает сущность и передаёт работу сервису:
<?php
use Drupal\node\NodeInterface;
function site_label_node_presave(NodeInterface $node): void {
if ($node->bundle() !== 'article') {
return;
}
\Drupal::service('site_label.label_manager')->apply($node);
}
Такой вызов через \Drupal::service() допустим в процедурном хуке, где конструктор недоступен. Но саму бизнес-логику держите в сервисе, а не в этом файле.
Что должен делать сервис
Сервис принимает объект, проверяет условия и меняет только то, за что отвечает. Он не должен сам решать, откуда пришёл вызов — из хука, формы или очереди.
<?php
namespace Drupal\site_label;
use Drupal\node\NodeInterface;
final class LabelManager {
public function apply(NodeInterface $node): void {
$label = trim((string) $node->getTitle());
if ($label === '') {
return;
}
$node->setTitle('[Проверено] ' . $label);
}
}
В реальном проекте перед изменением поля проверьте, что обработчик не запускается повторно при последующем сохранении. Для сложной маркировки лучше хранить отдельное поле, а не менять заголовок, который пользователь редактирует вручную.
Приём 2. Объявляйте сервисы явно
Минимальное описание сервиса находится в site_label.services.yml. Имена сервисов должны быть пространством модуля: это снижает риск конфликта с ядром и сторонними расширениями.
services:
site_label.label_manager:
class: Drupal\site_label\LabelManager
Если классу нужна конфигурация или другой сервис, добавьте зависимость через arguments, а не вызывайте контейнер внутри каждого метода:
services:
site_label.label_manager:
class: Drupal\site_label\LabelManager
arguments: ['@config.factory']
После изменения YAML очистите кеш контейнера. На сайте с Drush это обычно делают командой drush cache:rebuild; на хостинге без Drush — через административную страницу очистки кеша. Если контейнер не собирается, первым делом проверьте отступы YAML, имя класса и совпадение имени сервиса с тем, что запрашивает код.
Приём 3. Контроллер возвращает render array, а не готовый HTML
Для страницы модуля контроллер должен вернуть render array. Drupal сможет применить кеширование, перевести строку и обработать вывод перед отправкой браузеру.
Маршрут в site_label.routing.yml:
site_label.status:
path: '/site-label/status'
defaults:
_controller: '\Drupal\site_label\Controller\StatusController::page'
_title: 'Статус маркировки'
requirements:
_permission: 'access site label status'
Разрешение объявляется отдельно в site_label.permissions.yml:
access site label status:
title: 'Просматривать статус маркировки'
description: 'Разрешает открывать страницу статуса модуля'
Контроллер получает сервис через конструктор. В примере ниже используется API, совместимый с Drupal 10.x и 11.x:
<?php
namespace Drupal\site_label\Controller;
use Drupal\Core\Controller\ControllerBase;
use Drupal\site_label\LabelManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
final class StatusController extends ControllerBase {
public function __construct(
private readonly LabelManager $labelManager,
) {}
public static function create(ContainerInterface $container): static {
return new static(
$container->get('site_label.label_manager'),
);
}
public function page(): array {
return [
'#type' => 'markup',
'#plain_text' => 'Модуль маркировки подключён.',
'#cache' => [
'max-age' => 300,
],
];
}
}
Если текст зависит от пользователя, роли, языка или query-параметра, добавьте соответствующий cache context. Например, для ответа, который отличается по ролям, потребуется 'contexts' => ['user.roles']. Иначе один пользователь может увидеть закешированный результат другого.
Приём 4. Плагин нужен для взаимозаменяемых реализаций
Плагин оправдан, если у задачи есть несколько реализаций: разные способы расчёта скидки, источники данных или форматы экспорта. Если реализация всегда одна, сервис проще и дешевле в сопровождении.
Типичная ошибка — добавлять плагин только ради «расширяемости», а затем писать собственный менеджер, конфигурационную форму и тесты для единственного класса. Это увеличивает поверхность кода без реальной пользы. Сначала определите контракт: какие входные данные получает реализация, что возвращает и как сообщается об ошибке.
Что ломается чаще всего
- Доступ проверен только в меню. Скрытый пункт не защищает маршрут. Разрешение должно находиться в
_permissionили в другом требовании маршрута. - Кеш не учитывает контекст. Персональный или языковой результат без cache context становится общим для посетителей. Проверяйте ответ от имени пользователя с другой ролью и в другом языке.
- Модуль вызывает несуществующий сервис. Сообщение о невозможности найти сервис обычно означает опечатку в имени либо неочищенный кеш контейнера.
- Хук изменяет сущность повторно. При сохранении появляются дублирующиеся префиксы или бесконечная цепочка обновлений. Делайте операцию идемпотентной*: повторный запуск не должен менять уже обработанное значение.
- В контроллере собрана вся предметная логика. Страница работает, но очередь, форма и CLI-команда начинают копировать код. Вынесите вычисление в сервис до появления второй копии.
* Идемпотентная операция — действие, повторение которого приводит к тому же результату, что и один запуск.
Как выбрать подход на ревью
Начните с трёх вопросов:
- Это реакция на конкретное событие Drupal или самостоятельная операция?
- Будет ли операция вызвана из нескольких мест?
- Нужно ли выбирать реализацию через конфигурацию или расширять её сторонними модулями?
Ответ «самостоятельная операция» обычно ведёт к сервису. Ответ «несколько реализаций» — к плагину. Ответ «одно событие ядра» — к хуку, который передаёт данные сервису. Подписчик события выбирайте, когда событие уже существует в Symfony или Drupal, а привязывать решение к конкретному хуку не нужно.
Что сделать дальше
Соберите минимальный модуль на Drupal 10.x или 11.x: один маршрут, одно разрешение и один сервис. Затем проверьте три сценария — анонимный пользователь, пользователь с другой ролью и повторный вызов операции. После этого добавьте автоматический тест для сервиса и посмотрите в проде на ошибки контейнера, время ответа маршрута и долю кешированных ответов. Документация по созданию модулей и Configuration API поможет выбрать следующий слой, когда простой сервис перестанет справляться.
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: Creating modules — Drupal.org, Configuration API — Drupal.org
Частые вопросы
Можно ли оставить всю логику в файле MODULE.module?
Когда сервис лучше плагина?
Почему после изменения services.yml Drupal всё ещё видит старую конфигурацию?
Что будет, если не добавить cache context для персональной страницы?
Можно ли вызывать сервис через глобальный контейнер?
Подойдёт ли этот код для другой версии Drupal?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.