Drupal 11.2.x — это не просто “очередной точечный релиз”. Это момент, когда современные ожидания Drupal становятся жесткими:
- PHP 8.3+ является базовой линией.
- OO хуки с атрибутами заменяют традиционные реализации хуков в
.module. - Ограничения валидации обеспечивают лучший UX, чем выбрасывание исключений.
- SDC является первоклассным рабочим процессом тематизации.
- Правильные стратегии кеширования необходимы для контента, основанного на времени.
Это руководство создает Promo Kit (promo_kit) — полный модуль пользовательской контентной сущности с современной архитектурой Drupal 11.2:
✅ Пользовательская контентная сущность (promo_kit_promo) с полными CRUD операциями
✅ Базовые поля (изображение/ссылка/диапазон дат/стиль), определенные в коде
✅ Сервис PromoManager (фильтрация на основе запросов для активных промо)
✅ Плагин блока (отображает активные промо с умным кешированием)
✅ Ограничения валидации (правильный UX валидации форм)
✅ OO хуки (современные реализации хуков на основе атрибутов)
✅ SDC тематизация (Single Directory Components для фронтенда)
0. Что мы строим
Требование
Маркетологам нужны промо-баннеры, которые появляются на сайте.
- Поля: Метка, Описание, Баннерное изображение, Ссылка, Стиль (Alert/Info/Party), Расписание (диапазон дат начала/окончания)
- Логика: Промо активно, если:
- статус = опубликовано (включено)
- начало пустое или начало ≤ сейчас
- конец пустой или конец > сейчас
- Фронтенд: Отрисовывается выделенным Single Directory Component (SDC)
- Валидация: Дата окончания не может быть раньше даты начала (валидируется через ограничение)
Архитектура
- Пользовательская контентная сущность:
promo_kit_promo(не Block Content) - Базовые поля: Определены в
Promo::baseFieldDefinitions() - Сервис PromoManager: Обрабатывает запросы активных промо с правильной фильтрацией по датам
- Плагин блока:
PromoBannerBlockотображает активные промо - Ограничение валидации:
PromoDateRangeConstraintвалидирует логику дат - OO хуки: Класс
PromoHooksдля валидации presave сущности - SDC компонент:
promo_bannerдля тематизации
1. Окружение: Строгий и современный Drupal 11 (PHP 8.3+)
Drupal 11 строг к современному PHP. Если ваш локальный стек “достаточно близок”, этого недостаточно.
Установка DDEV (Homebrew)
# MacOS / Linux (Homebrew)
brew install ddev/ddev/ddevСоздание проекта Drupal 11
composer create-project drupal/recommended-project:^11 promo-site
cd promo-site
ddev config --project-type=drupal11 --docroot=web --php-version=8.3
ddev start
ddev composer require drush/drush:^13
ddev drush site:install -y2. Модуль Promo Kit: Каркас
Генерируем модуль:
ddev drush generate moduleИспользуйте подсказки типа:
- Имя модуля: Promo Kit
- Машинное имя: promo_kit
- Описание: Управляет промо-баннерами с интеграцией SDC.
- Зависимости: datetime_range, link, file
- Создать файл
.module? Да (мы будем использовать его для тематических хуков) - Создать файл install? Да (опционально)
Редактируем web/modules/custom/promo_kit/promo_kit.info.yml:
name:'Promo Kit'
type: module
description:'Управляет промо-баннерами с интеграцией SDC.'
package: Custom
core_version_requirement: ^10 || ^11
dependencies:
- datetime_range:datetime_range
- file:file
- link:link3. Пользовательская контентная сущность: Промо-баннер
Создаем класс сущности:
web/modules/custom/promo_kit/src/Entity/Promo.php
Эта сущность расширяет EditorialContentEntityBase, которая предоставляет:
- Ревизии
- Рабочий процесс публикации (поле статуса)
- Поддержку переводов
- Интеграцию модерации
Ключевые особенности:
- ID сущности:
promo_kit_promo(с префиксом для избежания коллизий) - Базовые поля: label, status, description, date_range, link, image, style
- Таблицы:
promo_kit_promo,promo_kit_promo_field_data,promo_kit_promo_revision,promo_kit_promo_field_revision
Сущность использует атрибуты PHP 8 для конфигурации:
#[ContentEntityType(
id: 'promo_kit_promo',
label: new TranslatableMarkup('Промо-баннер'),
// ... обработчики, ссылки и т.д.
)]Базовые поля включают:
label: Заголовок/имя промоstatus: Опубликовано/неопубликованоdescription: Длинное текстовое описаниеpromo_date_range: Поле диапазона дат (начало/конец)promo_link: Поле ссылкиpromo_image: Поле изображенияpromo_style: Поле списка (info/alert/party)uid: Ссылка на владельцаcreated/changed: Временные метки
4. Сервис PromoManager: Умная фильтрация на основе запросов
Создаем сервис:
web/modules/custom/promo_kit/src/PromoManager.php
Этот сервис предоставляет:
4.1 Метод getActivePromos()
Возвращает активные промо, перенося фильтрацию по датам в запрос сущности (не загружая все и фильтруя в PHP):
public function getActivePromos(): array {
$now = new DrupalDateTime('now', 'UTC');
$now_string = $now->format('Y-m-d\\TH:i:s');
$storage = $this->entityTypeManager->getStorage('promo_kit_promo');
$query = $storage->getQuery();
$query->accessCheck(TRUE)
->condition('status', 1);
// Логика фильтрации дат с группами OR/AND
// ...
$ids = $query->execute();
return $storage->loadMultiple($ids);
}Ключевые моменты:
- Использует часовой пояс UTC (Drupal хранит даты в UTC)
- Строит сложные условия запроса для диапазонов дат
- Обрабатывает пустые поля дат (всегда активно, если даты не установлены)
- Масштабируемо: фильтрует на уровне базы данных, а не в PHP
4.2 Метод getSecondsUntilNextBoundary()
Вычисляет динамический cache max-age на основе того, когда следующее промо начнется или закончится:
public function getSecondsUntilNextBoundary(): int {
// Найти ближайшую будущую дату начала/окончания
// Вернуть секунды до этой границы
// По умолчанию: 3600 (1 час)
}Это гарантирует, что кеш блока истекает в нужное время, когда изменяется видимость промо.
Регистрируем сервис в promo_kit.services.yml:
services:
promo_kit.manager:
class: Drupal\\promo_kit\\PromoManager
arguments:['@entity_type.manager']5. Плагин блока: Отображение активных промо
Создаем плагин блока:
web/modules/custom/promo_kit/src/Plugin/Block/PromoBannerBlock.php
Этот блок:
- Внедряет
PromoManagerиEntityTypeManagerInterface - Вызывает
$this->promoManager->getActivePromos() - Отрисовывает каждое промо с помощью view builder
- Реализует правильное кеширование:
- Теги кеша:
promo_kit_promo_list(инвалидируется при изменении любого промо) - Контексты кеша:
user.permissions(проверка доступа влияет на результаты) - Cache max-age: Динамический на основе следующей границы
#[Block(
id: "promo_banner_block",
admin_label: new TranslatableMarkup("Промо-баннеры"),
category: new TranslatableMarkup("Promo Kit")
)]
class PromoBannerBlock extends BlockBase implements ContainerFactoryPluginInterface {
public function build(): array {
$active_promos = $this->promoManager->getActivePromos();
if (empty($active_promos)) {
return [];
}
$view_builder = $this->entityTypeManager->getViewBuilder('promo_kit_promo');
$build = [
'#theme' => 'item_list',
'#items' => [],
];
foreach ($active_promos as $promo) {
$build['#items'][] = $view_builder->view($promo, 'default');
}
return $build;
}
public function getCacheMaxAge(): int {
return $this->promoManager->getSecondsUntilNextBoundary();
}
}6. Ограничение валидации: Логика диапазона дат
Вместо выбрасывания исключений в presave (что вызывает белые экраны), используйте ограничение валидации для правильного отображения ошибок формы.
6.1 Плагин ограничения
web/modules/custom/promo_kit/src/Plugin/Validation/Constraint/PromoDateRangeConstraint.php
#[Constraint(
id: 'PromoDateRange',
label: new TranslatableMarkup('Диапазон дат промо', [], ['context' => 'Validation']),
type: ['entity:promo_kit_promo']
)]
class PromoDateRangeConstraint extends SymfonyConstraint {
public string $message = 'Дата окончания не может быть раньше даты начала.';
}6.2 Валидатор ограничения
web/modules/custom/promo_kit/src/Plugin/Validation/Constraint/PromoDateRangeConstraintValidator.php
class PromoDateRangeConstraintValidator extends ConstraintValidator {
public function validate(mixed $entity, Constraint $constraint): void {
if (!isset($entity)) {
return;
}
$date_field = 'promo_date_range';
if (!$entity->hasField($date_field) || $entity->get($date_field)->isEmpty()) {
return;
}
$start = $entity->get($date_field)->value;
$end = $entity->get($date_field)->end_value;
if ($end && $start && $end < $start) {
$this->context->addViolation($constraint->message);
}
}
}6.3 Привязка ограничения к сущности
Ограничение привязывается на уровне сущности (type: entity:promo_kit_promo), поэтому оно валидируется автоматически во время валидации сущности.
Для ограничений на уровне поля вы бы использовали hook_entity_bundle_field_info_alter() для привязки ограничения к конкретному полю.
7. OO хуки: Современный подход Drupal 11.2
Создаем класс хука:
web/modules/custom/promo_kit/src/Hook/PromoHooks.php
class PromoHooks {
use StringTranslationTrait;
#[Hook('entity_presave')]
public function validateDates(EntityInterface $entity): void {
if ($entity->getEntityTypeId() !== 'promo_kit_promo') {
return;
}
$date_field = 'promo_date_range';
if ($entity->hasField($date_field) && !$entity->get($date_field)->isEmpty()) {
$start = $entity->get($date_field)->value;
$end = $entity->get($date_field)->end_value;
if ($end && $start && $end < $start) {
throw new \\InvalidArgumentException('Логическая ошибка: Дата окончания промо не может быть раньше даты начала.');
}
}
}
}Примечание: Эта валидация presave является резервной. Ограничение обеспечивает лучший UX, показывая ошибки формы. Хук presave ловит любые программные сохранения, которые обходят валидацию формы.
После добавления классов хуков очистите кеш:
ddev drush cr8. Фронтенд: Single Directory Components (SDC)
Создаем структуру компонента:
mkdir -p web/modules/custom/promo_kit/components/promo_banner8.1 Определение компонента
web/modules/custom/promo_kit/components/promo_banner/promo_banner.component.yml
name: Промо-баннер
status: stable
props:
type: object
properties:
title:{type: string}
link_url:{type: string}
style_variant:{type: string,enum:['alert','party','info']}
image:{type: object}8.2 Шаблон компонента
web/modules/custom/promo_kit/components/promo_banner/promo_banner.twig
<div class="promo-banner promo-banner--{{ style_variant|default('info') }}">
{% if image %}
<div class="promo-banner__image">
{{ image }}
</div>
{% endif %}
<div class="promo-content">
{% if title %}
<strong class="promo-title">{{ title }}</strong>
{% endif %}
{% if link_url %}
<a href="{{ link_url }}" class="promo-link">Посмотреть →</a>
{% endif %}
</div>
</div>8.3 Стили компонента
web/modules/custom/promo_kit/components/promo_banner/promo_banner.css
.promo-banner {
padding: 1rem;
border-radius: 0.5rem;
margin: 1rem 0;
border-left: 4px solid;
display: flex;
gap: 1rem;
align-items: center;
}
.promo-banner__image { flex-shrink: 0; }
.promo-banner__image img {
max-width: 150px;
height: auto;
border-radius: 0.25rem;
display: block;
}
.promo-banner .promo-content {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
flex: 1;
}
.promo-banner .promo-content strong {
font-size: 1.2rem;
font-weight: 600;
flex: 1;
}
.promo-banner .promo-content a {
color: inherit;
text-decoration: none;
font-weight: 500;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
transition: opacity 0.2s;
}
.promo-banner .promo-content a:hover {
opacity: 0.8;
text-decoration: underline;
}
/* Вариант Info (по умолчанию) */
.promo-banner--info {
background-color: #e7f3ff;
border-left-color: #2196f3;
color: #0d47a1;
}
.promo-banner--info .promo-content a {
background-color: #2196f3;
color: white;
}
.promo-banner--info .promo-content a:hover {
background-color: #1976d2;
text-decoration: none;
}
/* Вариант Alert */
.promo-banner--alert {
background-color: #fff3cd;
border-left-color: #ff9800;
color: #e65100;
}
.promo-banner--alert .promo-content a {
background-color: #ff9800;
color: white;
}
.promo-banner--alert .promo-content a:hover {
background-color: #f57c00;
text-decoration: none;
}
/* Вариант Party */
.promo-banner--party {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-left-color: #764ba2;
color: white;
}
.promo-banner--party .promo-content a {
background-color: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
}
.promo-banner--party .promo-content a:hover {
background-color: rgba(255, 255, 255, 0.3);
text-decoration: none;
}9. Подключение вывода сущности к SDC
9.1 Тематический хук
Регистрируем тематический хук в promo_kit.module:
function promo_kit_theme(): array {
return [
'promo_kit_promo' => ['render element' => 'elements'],
];
}
function template_preprocess_promo_kit_promo(array &$variables): void {
$variables['view_mode'] = $variables['elements']['#view_mode'];
foreach (Element::children($variables['elements']) as $key) {
$variables['content'][$key] = $variables['elements'][$key];
}
}9.2 Шаблон сущности
web/modules/custom/promo_kit/templates/promo-kit-promo.html.twig
{#
/**
* @file
* Переопределение темы для сущности промо-баннера.
*/
#}
{# Получаем метку сущности #}
{% set title_text = label|default('') %}
{# Получаем URL ссылки из поля, если оно существует #}
{% set link_uri = null %}
{% if content.promo_link[0]['#url'] is defined %}
{% set link_uri = content.promo_link[0]['#url'].toString() %}
{% endif %}
{# Получаем вариант стиля, по умолчанию 'info' #}
{% set style_variant = 'info' %}
{% if promo_kit_promo.promo_style.value is defined %}
{% set style_variant = promo_kit_promo.promo_style.value %}
{% endif %}
{# Получаем разметку изображения из отрисованного контента #}
{% set image_markup = content.promo_image|default(null) %}
{# Используем SDC компонент #}
{{ include('promo_kit:promo_banner', {
title: title_text,
link_url: link_uri,
style_variant: style_variant,
image: image_markup
}, with_context = false) }}10. Разрешения и маршруты
10.1 Разрешения
promo_kit.permissions.yml
administer promo_kit_promo:
title:'Администрировать промо-баннеры'
description:'Создавать, редактировать, удалять и управлять промо-баннерами.'
restrict access:true
view promo_kit_promo:
title:'Просматривать промо-баннеры'
description:'Просматривать опубликованные промо-баннеры.'
create promo_kit_promo:
title:'Создавать промо-баннеры'
description:'Создавать новые промо-баннеры.'
edit promo_kit_promo:
title:'Редактировать промо-баннеры'
description:'Редактировать существующие промо-баннеры.'
delete promo_kit_promo:
title:'Удалять промо-баннеры'
description:'Удалять промо-баннеры.'10.2 Маршруты
promo_kit.routing.yml
entity.promo_kit_promo.settings:
path:'admin/structure/promo-kit-promo'
defaults:
_form:'\\Drupal\\promo_kit\\Form\\PromoSettingsForm'
_title:'Промо-баннер'
requirements:
_permission:'administer promo_kit_promo'Маршруты сущности (добавить, редактировать, удалить, список) автоматически генерируются AdminHtmlRouteProvider, указанным в аннотации сущности.
11. Включение и использование
Включаем модуль:
ddev drush en promo_kit -y
ddev drush cr11.1 Создание промо
Перейдите к: /admin/content/promo/add
Примерные значения:
- Метка: “Летняя распродажа 2025”
- Описание: “Получите скидку 50% на все товары!”
- Диапазон дат: Начало сегодня, окончание через 30 дней
- Ссылка:
https://example.com/sale - Стиль: party
- Баннерное изображение: Загрузите изображение
- Статус: Включено
11.2 Размещение блока
Перейдите к: /admin/structure/block
- Нажмите “Разместить блок”
- Найдите “Промо-баннеры” (из категории Promo Kit)
- Разместите в желаемом регионе (например, Заголовок, Контент)
- Настройте параметры видимости при необходимости
- Сохраните
Блок автоматически будет отображать только активные промо на основе текущей даты/времени.
12. Полный чек-лист тестирования
12.1 CRUD операции промо
| Тест | Шаги | Ожидаемое |
|---|---|---|
| Создать промо | /admin/content/promo/add | Успешно сохранено |
| Редактировать промо | Редактировать существующее промо | Изменения сохранены |
| Удалить промо | Удалить промо | Удалено из списка |
| Ревизии | Включить ревизии, внести изменения | Появляется история ревизий |
| Перевод | Добавить язык, перевести | Переведенная версия отображается |
12.2 Логика расписания
| Сценарий | Начало | Конец | Ожидаемое |
|---|---|---|---|
| Без расписания | пусто | пусто | видимо |
| Будущее начало | завтра | пусто | скрыто до завтра |
| Прошлое начало | вчера | пусто | видимо |
| Будущий конец | пусто | следующая неделя | видимо |
| Прошлый конец | пусто | вчера | скрыто |
| Активный диапазон | вчера | завтра | видимо |
| Неверный диапазон | следующая неделя | вчера | ошибка валидации формы |
12.3 Валидация
| Тест | Шаги | Ожидаемое |
|---|---|---|
| Конец раньше начала | Установить конец раньше начала, сохранить | Отображается ошибка формы |
| Верный диапазон | Установить правильный диапазон, сохранить | Успешно сохраняется |
| Пустые даты | Оставить даты пустыми, сохранить | Успешно сохраняется |
12.4 Варианты стилей
| Стиль | Ожидаемое |
|---|---|
| info | Синяя тема |
| alert | Желто-оранжевая тема |
| party | Фиолетовый градиент |
| пусто/по умолчанию | По умолчанию info |
12.5 Отображение блока
| Тест | Шаги | Ожидаемое |
|---|---|---|
| Разместить блок | Макет блока | Видим в регионе |
| Нет активных промо | Все промо истекли/отключены | Блок отображается пустым |
| Несколько промо | Создать 3 активных промо | Все 3 отображаются |
| Инвалидация кеша | Редактировать промо | Блок обновляется немедленно |
| Кеш на основе времени | Ждать истечения промо | Блок обновляется после истечения кеша |
13. Продвинутое: Глубокое погружение в стратегию кеширования
Модуль реализует сложную стратегию кеширования для контента, основанного на времени:
13.1 Теги кеша
public function getCacheTags(): array {
return Cache::mergeTags(parent::getCacheTags(), ['promo_kit_promo_list']);
}Тег promo_kit_promo_list автоматически инвалидируется когда:
- Создается любое промо
- Обновляется любое промо
- Удаляется любое промо
Это гарантирует, что блок обновляется немедленно при изменении контента.
13.2 Контексты кеша
public function getCacheContexts(): array {
return Cache::mergeContexts(parent::getCacheContexts(), ['user.permissions']);
}Контекст user.permissions гарантирует, что разные пользователи видят соответствующий контент на основе их разрешений доступа.
13.3 Динамический Cache Max-Age
public function getCacheMaxAge(): int {
return $this->promoManager->getSecondsUntilNextBoundary();
}Вместо фиксированной продолжительности кеша (например, 1 час), блок вычисляет, когда следующее промо начнется или закончится, и устанавливает истечение кеша на этот точный момент.
Пример:
- Текущее время: 14:00
- Промо A заканчивается: 14:30
- Промо B начинается: 15:00
- Cache max-age: 1800 секунд (30 минут, до окончания Промо A)
Это гарантирует, что промо появляются/исчезают в правильное время без избыточного или недостаточного кеширования.
14. Устранение неполадок
“Я не вижу стилизацию SDC”
- Подтвердите существование файлов компонента:
components/promo_banner/promo_banner.component.ymlcomponents/promo_banner/promo_banner.twigcomponents/promo_banner/promo_banner.css
- Очистите кеш:
ddev drush cr- Проверьте имя файла шаблона:
- Должно быть:
promo-kit-promo.html.twig - Расположение: директория
templates/
- Должно быть:
“Блок показывает истекшие промо”
- Проверьте диапазоны дат промо в админ UI
- Убедитесь, что часовой пояс сервера соответствует ожидаемому часовому поясу
- Очистите кеш:
ddev drush cr- Проверьте логику запроса PromoManager в
getActivePromos()
“Валидация не срабатывает”
- Подтвердите существование файлов ограничений и правильность их пространств имен
- Очистите кеш дважды (обнаружение плагинов может быть липким):
ddev drush cr
ddev drush cr- Проверьте тип сущности в аннотации ограничения:
type: ['entity:promo_kit_promo']
“Ошибки отказа в доступе”
- Предоставьте соответствующие разрешения:
/admin/people/permissions- Найдите “promo”
- Предоставьте разрешения соответствующим ролям
- Очистите кеш:
ddev drush cr15. Что мы построили (Сводка архитектуры)
Пользовательская контентная сущность
- ID сущности:
promo_kit_promo - Базовый класс:
EditorialContentEntityBase - Особенности: Ревизии, переводы, рабочий процесс публикации
- Таблицы: 4 таблицы (base, field_data, revision, field_revision)
Слой сервисов
- PromoManager: Бизнес-логика для запроса активных промо
- Методы:
getActivePromos(),getSecondsUntilNextBoundary() - Преимущества: Переиспользуемый, тестируемый, фильтрация на основе запросов
Плагин блока
- ID:
promo_banner_block - Особенности: Внедрение зависимостей, правильное кеширование
- Кеширование: Теги, контексты, динамический max-age
Валидация
- Ограничение:
PromoDateRangeConstraint - Валидатор:
PromoDateRangeConstraintValidator - UX: Ошибки формы вместо исключений
OO хуки
- Класс:
PromoHooks - Хук:
entity_presaveс атрибутом#[Hook] - Цель: Резервная валидация для программных сохранений
SDC тематизация
- Компонент:
promo_banner - Файлы: component.yml, twig, css
- Интеграция: Шаблон сущности включает SDC компонент
16. Когда использовать пользовательские сущности против Block Content
Используйте пользовательские сущности когда:
- Вам нужна пользовательская логика хранения/запросов
- У вас есть сложные бизнес-правила
- Вам нужны пользовательские маршруты/UI
- Вы хотите полный контроль над моделью данных
- Вы строите специфичный для домена тип контента
Используйте Block Content когда:
- Вам нужны простые переиспользуемые блоки контента
- Функций основного Block Content достаточно
- Вы хотите использовать Views для отображения
- Вам не нужна пользовательская логика запросов
- Вы хотите поддерживать меньше кода
Promo Kit использует пользовательскую сущность потому что:
- Нам нужна пользовательская логика запросов для фильтрации на основе дат
- Мы хотим выделенный слой сервисов (PromoManager)
- У нас есть специфические требования к кешированию
- Мы хотим полный контроль над структурой сущности
Заключение
Мы построили Promo Kit, используя современные лучшие практики Drupal 11.2:
- Пользовательская контентная сущность с правильным префиксом (
promo_kit_promo) - Слой сервисов (PromoManager) для бизнес-логики
- Фильтрация на основе запросов (масштабируемая, не загружающая все сущности)
- Плагин блока с правильным внедрением зависимостей
- Сложное кеширование (теги, контексты, динамический max-age)
- Ограничения валидации (правильный UX)
- OO хуки (современный подход на основе атрибутов)
- SDC тематизация (компонентный фронтенд)
Эта архитектура готова к продакшену, поддерживаема и следует соглашениям Drupal 11.2. Модуль демонстрирует, как правильно строить пользовательские сущности — с правильным разделением ответственности, тестируемым кодом и отличной производительностью.
17. Дополнительные компоненты модуля
17.1 Обработчик контроля доступа
web/modules/custom/promo_kit/src/PromoAccessControlHandler.php
Обработчик контроля доступа управляет разрешениями для сущностей промо:
- Доступ на просмотр: Требует разрешение
view promo_kit_promo(и сущность должна быть опубликована) - Доступ на создание: Требует разрешение
create promo_kit_promo - Доступ на обновление: Требует разрешение
edit promo_kit_promo - Доступ на удаление: Требует разрешение
delete promo_kit_promo - Админ доступ:
administer promo_kit_promoобходит все проверки
17.2 Построитель списка
web/modules/custom/promo_kit/src/PromoListBuilder.php
Предоставляет страницу админ списка по адресу /admin/content/promo:
- Отображает все промо в таблице
- Показывает метку, статус, автора, дату создания
- Предоставляет ссылки операций редактирования/удаления
- Поддерживает массовые операции
17.3 Классы форм
PromoForm (src/Form/PromoForm.php)
- Форма добавления/редактирования для сущностей промо
- Обрабатывает виджеты полей
- Валидирует ввод
- Сохраняет сущность
PromoSettingsForm (src/Form/PromoSettingsForm.php)
- Форма конфигурации для типа сущности промо
- Доступна по адресу
/admin/structure/promo-kit-promo - Позволяет управление полями через Field UI
17.4 Построитель представления
web/modules/custom/promo_kit/src/PromoViewBuilder.php
Отрисовывает сущности промо:
- Строит массивы рендера для отображения
- Применяет режимы просмотра
- Интегрируется с системой тем
- Обрабатывает форматеры полей
17.5 Хуки интеграции пользователей
В promo_kit.module мы обрабатываем операции с учетными записями пользователей:
hook_user_cancel()
user_cancel_block_unpublish: Снимает с публикации промо пользователяuser_cancel_reassign: Переназначает промо анонимному пользователю
hook_user_predelete()
- Удаляет все промо, принадлежащие пользователю
- Удаляет все ревизии промо, принадлежащие пользователю
Это обеспечивает целостность данных при удалении учетных записей пользователей.
18. Управление конфигурацией
18.1 Экспорт конфигурации
Если вы добавляете поля через Field UI (не базовые поля), экспортируйте их:
ddev drush cex -y18.2 Поставка конфигурации по умолчанию
Скопируйте соответствующие YAML файлы из config/sync/ в config/install/:
# Пример: Если вы добавили поле через UI
cp config/sync/field.storage.promo_kit_promo.field_custom.yml \\
web/modules/custom/promo_kit/config/install/
cp config/sync/field.field.promo_kit_promo.promo_kit_promo.field_custom.yml \\
web/modules/custom/promo_kit/config/install/Модуль в настоящее время поставляется с минимальной конфигурацией (только конфигурации действий для массовых операций).
18.3 Конфигурационные поля против базовых полей
Базовые поля (что мы используем):
- Определены в коде (
baseFieldDefinitions()) - Всегда присутствуют, не могут быть удалены через UI
- Переносимы между окружениями
- Контролируются версиями в коде
Конфигурационные поля (альтернатива):
- Определены через Field UI
- Хранятся как YAML конфигурация
- Могут быть добавлены/удалены через UI
- Требуют управления конфигурацией для переносимости
Promo Kit использует базовые поля потому что:
- Структура полей фиксирована (не настраивается строителем сайта)
- Мы хотим гарантированное присутствие полей
- Мы хотим контроль версий на основе кода
- Нам не нужна настройка полей для каждого сайта
19. Стратегии тестирования
19.1 Чек-лист ручного тестирования
✅ CRUD сущности
- Создать промо со всеми полями
- Редактировать промо, изменить значения
- Удалить промо
- Просмотреть список промо по адресу
/admin/content/promo
✅ Логика дат
- Создать промо без дат (должно всегда показываться)
- Создать промо с будущим началом (должно скрываться до начала)
- Создать промо с прошлым концом (должно скрываться)
- Создать промо с активным диапазоном (должно показываться)
✅ Валидация
- Попытаться сохранить промо с концом раньше начала (должно показать ошибку)
- Сохранить промо с валидными датами (должно успешно выполниться)
✅ Отображение блока
- Разместить блок в регионе
- Убедиться, что отображаются только активные промо
- Отключить промо, убедиться, что оно исчезает
- Создать новое промо, убедиться, что оно появляется
✅ Кеширование
- Редактировать промо, убедиться, что блок обновляется немедленно
- Ждать истечения промо, убедиться, что оно исчезает (может занять до cache max-age)
✅ Разрешения
- Тестировать как анонимный пользователь (не должен видеть админ страницы)
- Тестировать как аутентифицированный пользователь с разрешением на просмотр (должен видеть промо)
- Тестировать как админ (должен видеть все)
19.2 Автоматизированное тестирование (опционально)
Для продакшн модулей рассмотрите добавление:
Юнит тесты
- Тестировать логику PromoManager
- Тестировать вычисления дат
- Тестировать валидатор ограничений
Kernel тесты
- Тестировать CRUD операции сущности
- Тестировать логику запросов
- Тестировать контроль доступа
Функциональные тесты
- Тестировать отправку форм
- Тестировать отображение блока
- Тестировать разрешения
Пример структуры тестов:
tests/
src/
Unit/
PromoManagerTest.php
Kernel/
PromoEntityTest.php
Functional/
PromoBlockTest.php20. Соображения производительности
20.1 Оптимизация запросов
PromoManager использует запросы сущностей с условиями, не загружая все сущности:
✅ Хорошо (что мы делаем):
$query->condition('status', 1)
->condition('promo_date_range.value', $now, '<=');
$ids = $query->execute();
$promos = $storage->loadMultiple($ids);❌ Плохо (не делайте так):
$all_promos = $storage->loadMultiple();
$active = array_filter($all_promos, function($promo) {
// Фильтрация в PHP
});20.2 Стратегия кеширования
Блок реализует три измерения кеширования:
- Теги кеша: Инвалидировать при изменении контента
- Контексты кеша: Варьировать по разрешениям пользователя
- Cache Max-Age: Истекать на следующей границе
Это обеспечивает:
- Свежий контент при редактировании промо
- Правильный контент для разных пользователей
- Своевременные обновления при истечении/активации промо
20.3 Индексы базы данных
Сущность автоматически получает индексы на:
id(первичный ключ)uuid(уникальный)revision_idlangcodestatus
Для высоконагруженных сайтов с множеством промо рассмотрите добавление пользовательских индексов на:
promo_date_range.value(дата начала)promo_date_range.end_value(дата окончания)
Это можно сделать в hook_schema_alter() или хуке обновления.
21. Расширение модуля
21.1 Добавление поля веса
Для контроля порядка отображения:
- Добавить базовое поле в
Promo::baseFieldDefinitions():
$fields['weight'] = BaseFieldDefinition::create('integer')
->setLabel(t('Вес'))
->setDescription(t('Меньшие веса появляются первыми.'))
->setDefaultValue(0)
->setDisplayOptions('form', [
'type' => 'number',
'weight' => 20,
]);- Обновить запрос PromoManager:
$query->sort('weight', 'ASC');21.2 Добавление категорий/тегов
Для категоризации промо:
- Добавить поле ссылки на сущность:
$fields['category'] = BaseFieldDefinition::create('entity_reference')
->setLabel(t('Категория'))
->setSetting('target_type', 'taxonomy_term')
->setSetting('handler', 'default:taxonomy_term')
->setSetting('handler_settings', [
'target_bundles' => ['promo_categories' => 'promo_categories'],
]);- Создать словарь таксономии
promo_categories - Обновить блок для фильтрации по категории (добавить конфигурацию блока)
21.3 Добавление режимов просмотра
Для поддержки различных стилей отображения:
- Создать режим просмотра через UI или конфигурацию:
/admin/structure/display-modes/view/add/promo_kit_promo
- Настроить поля для режима просмотра:
/admin/structure/promo-kit-promo/display/[view_mode]
- Использовать в блоке:
$build['#items'][] = $view_builder->view($promo, 'teaser');21.4 Добавление поддержки REST API
Для предоставления промо через REST:
- Добавить зависимость в
promo_kit.info.yml:
dependencies:
- rest:rest- Включить REST ресурс:
ddev drush en rest -y- Настроить REST ресурс для типа сущности
promo_kit_promo - Доступ через:
/promo_kit_promo/{id}?_format=json
22. Чек-лист развертывания
22.1 Перед развертыванием
✅ Тестировать в staging окружении ✅ Убедиться, что все промо отображаются правильно ✅ Тестировать видимость на основе дат ✅ Тестировать ограничения валидации ✅ Тестировать разрешения для всех ролей ✅ Проверить поведение кеширования ✅ Проверить производительность с реалистичным объемом данных
22.2 Шаги развертывания
- Включить модуль:
drush en promo_kit -y- Очистить кеш:
drush cr- Импортировать конфигурацию (если используется управление конфигурацией):
drush cim -y- Запустить хуки обновления (если есть):
drush updb -y- Проверить разрешения:
drush role:perm:list authenticated- Разместить блок (если не в конфигурации):
- Через UI:
/admin/structure/block - Или через Drush:
drush block:place promo_banner_block
- Через UI:
22.3 После развертывания
✅ Убедиться, что блок появляется на фронтенде ✅ Создать тестовое промо и убедиться, что оно отображается ✅ Проверить логи ошибок на наличие проблем ✅ Мониторить производительность ✅ Обучить редакторов контента созданию промо
23. Распространенные подводные камни и решения
Подводный камень 1: Забывание accessCheck()
❌ Ошибка:
$query = $storage->getQuery();
$ids = $query->execute(); // Фатальная ошибка в D11✅ Решение:
$query = $storage->getQuery();
$query->accessCheck(TRUE); // или FALSE для внутренних запросов
$ids = $query->execute();Подводный камень 2: Проблемы с часовыми поясами
❌ Проблема: Даты не соответствуют ожидаемому поведению
✅ Решение: Всегда используйте UTC для сравнения дат:
$now = new DrupalDateTime('now', 'UTC');Подводный камень 3: Кеш не инвалидируется
❌ Проблема: Блок показывает устаревшие данные после редактирования промо
✅ Решение: Убедитесь, что теги кеша правильные:
public function getCacheTags(): array {
return Cache::mergeTags(parent::getCacheTags(), ['promo_kit_promo_list']);
}Подводный камень 4: Валидация не работает
❌ Проблема: Ограничение не срабатывает
✅ Решение: Очистите кеш дважды (обнаружение плагинов):
drush cr && drush crПодводный камень 5: Шаблон не найден
❌ Проблема: Ошибка “Template not found”
✅ Решение: Проверьте, что имя файла соответствует тематическому хуку:
- Хук:
promo_kit_promo - Шаблон:
promo-kit-promo.html.twig(подчеркивания становятся дефисами)
24. Ресурсы и дополнительное чтение
Официальная документация Drupal
Специфично для Drupal 11
Инструменты разработки
Приложение A: Полная структура файлов
promo_kit/
├── components/
│ └── promo_banner/
│ ├── promo_banner.component.yml
│ ├── promo_banner.twig
│ └── promo_banner.css
├── config/
│ └── install/
│ ├── field.field.promo_kit_promo.promo_kit_promo.field_date_range.yml
│ ├── field.storage.promo_kit_promo.field_date_range.yml
│ ├── system.action.promo_kit_promo_delete_action.yml
│ └── system.action.promo_kit_promo_save_action.yml
├── src/
│ ├── Entity/
│ │ └── Promo.php
│ ├── Form/
│ │ ├── PromoForm.php
│ │ └── PromoSettingsForm.php
│ ├── Hook/
│ │ └── PromoHooks.php
│ ├── Plugin/
│ │ ├── Block/
│ │ │ └── PromoBannerBlock.php
│ │ └── Validation/
│ │ └── Constraint/
│ │ ├── PromoDateRangeConstraint.php
│ │ └── PromoDateRangeConstraintValidator.php
│ ├── PromoAccessControlHandler.php
│ ├── PromoInterface.php
│ ├── PromoListBuilder.php
│ ├── PromoManager.php
│ └── PromoViewBuilder.php
├── templates/
│ └── promo-kit-promo.html.twig
├── promo_kit.info.yml
├── promo_kit.install
├── promo_kit.links.action.yml
├── promo_kit.links.contextual.yml
├── promo_kit.links.menu.yml
├── promo_kit.links.task.yml
├── promo_kit.module
├── promo_kit.permissions.yml
├── promo_kit.routing.yml
├── promo_kit.services.yml
└── README.mdПриложение B: Ключевые паттерны Drupal 11.2, используемые
1. Атрибуты PHP 8 для определения сущности
#[ContentEntityType(
id: 'promo_kit_promo',
label: new TranslatableMarkup('Промо-баннер'),
// ...
)]
class Promo extends EditorialContentEntityBase { }2. OO хуки с атрибутами
#[Hook('entity_presave')]
public function validateDates(EntityInterface $entity): void { }3. Ограничения валидации
#[Constraint(
id: 'PromoDateRange',
label: new TranslatableMarkup('Диапазон дат промо'),
)]
class PromoDateRangeConstraint extends SymfonyConstraint { }4. Продвижение свойств конструктора
public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}5. Типизированные свойства
public string $message = 'Дата окончания не может быть раньше даты начала.';6. Readonly свойства
protected readonly PromoManager $promoManager;7. Подсказки типов Mixed
public function validate(mixed $entity, Constraint $constraint): void { }Эти паттерны — все функции PHP 8.3+, которые Drupal 11.2 полностью принимает.
Конец руководства
Теперь у вас есть полный, готовый к продакшену модуль Drupal 11.2, который демонстрирует современные практики разработки. Модуль Promo Kit демонстрирует пользовательские сущности, слои сервисов, валидацию, кеширование, OO хуки и SDC тематизацию — все инструменты, необходимые для создания сложных приложений Drupal.