Drupal 11.2.x — это не просто “очередной точечный релиз”. Это момент, когда современные ожидания Drupal становятся жесткими:
.module.Это руководство создает Promo Kit (promo_kit) — полный модуль пользовательской контентной сущности с современной архитектурой Drupal 11.2:
✅ Пользовательская контентная сущность (promo_kit_promo) с полными CRUD операциями
✅ Базовые поля (изображение/ссылка/диапазон дат/стиль), определенные в коде
✅ Сервис PromoManager (фильтрация на основе запросов для активных промо)
✅ Плагин блока (отображает активные промо с умным кешированием)
✅ Ограничения валидации (правильный UX валидации форм)
✅ OO хуки (современные реализации хуков на основе атрибутов)
✅ SDC тематизация (Single Directory Components для фронтенда)
Маркетологам нужны промо-баннеры, которые появляются на сайте.
promo_kit_promo (не Block Content)Promo::baseFieldDefinitions()PromoBannerBlock отображает активные промоPromoDateRangeConstraint валидирует логику датPromoHooks для валидации presave сущностиpromo_banner для тематизацииDrupal 11 строг к современному PHP. Если ваш локальный стек “достаточно близок”, этого недостаточно.
# MacOS / Linux (Homebrew)
brew install ddev/ddev/ddevcomposer 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 -yГенерируем модуль:
ddev drush generate moduleИспользуйте подсказки типа:
.module? Да (мы будем использовать его для тематических хуков)Редактируем 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:linkСоздаем класс сущности:
web/modules/custom/promo_kit/src/Entity/Promo.php
Эта сущность расширяет EditorialContentEntityBase, которая предоставляет:
Ключевые особенности:
promo_kit_promo (с префиксом для избежания коллизий)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: Временные меткиСоздаем сервис:
web/modules/custom/promo_kit/src/PromoManager.php
Этот сервис предоставляет:
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);
}Ключевые моменты:
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']Создаем плагин блока:
web/modules/custom/promo_kit/src/Plugin/Block/PromoBannerBlock.php
Этот блок:
PromoManager и EntityTypeManagerInterface$this->promoManager->getActivePromos()promo_kit_promo_list (инвалидируется при изменении любого промо)user.permissions (проверка доступа влияет на результаты)#[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();
}
}Вместо выбрасывания исключений в presave (что вызывает белые экраны), используйте ограничение валидации для правильного отображения ошибок формы.
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 = 'Дата окончания не может быть раньше даты начала.';
}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);
}
}
}Ограничение привязывается на уровне сущности (type: entity:promo_kit_promo), поэтому оно валидируется автоматически во время валидации сущности.
Для ограничений на уровне поля вы бы использовали hook_entity_bundle_field_info_alter() для привязки ограничения к конкретному полю.
Создаем класс хука:
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 crСоздаем структуру компонента:
mkdir -p web/modules/custom/promo_kit/components/promo_bannerweb/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}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>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;
}Регистрируем тематический хук в 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];
}
}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) }}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:'Удалять промо-баннеры.'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, указанным в аннотации сущности.
Включаем модуль:
ddev drush en promo_kit -y
ddev drush crПерейдите к: /admin/content/promo/add
Примерные значения:
https://example.com/saleПерейдите к: /admin/structure/block
Блок автоматически будет отображать только активные промо на основе текущей даты/времени.
| Тест | Шаги | Ожидаемое |
|---|---|---|
| Создать промо | /admin/content/promo/add | Успешно сохранено |
| Редактировать промо | Редактировать существующее промо | Изменения сохранены |
| Удалить промо | Удалить промо | Удалено из списка |
| Ревизии | Включить ревизии, внести изменения | Появляется история ревизий |
| Перевод | Добавить язык, перевести | Переведенная версия отображается |
| Сценарий | Начало | Конец | Ожидаемое |
|---|---|---|---|
| Без расписания | пусто | пусто | видимо |
| Будущее начало | завтра | пусто | скрыто до завтра |
| Прошлое начало | вчера | пусто | видимо |
| Будущий конец | пусто | следующая неделя | видимо |
| Прошлый конец | пусто | вчера | скрыто |
| Активный диапазон | вчера | завтра | видимо |
| Неверный диапазон | следующая неделя | вчера | ошибка валидации формы |
| Тест | Шаги | Ожидаемое |
|---|---|---|
| Конец раньше начала | Установить конец раньше начала, сохранить | Отображается ошибка формы |
| Верный диапазон | Установить правильный диапазон, сохранить | Успешно сохраняется |
| Пустые даты | Оставить даты пустыми, сохранить | Успешно сохраняется |
| Стиль | Ожидаемое |
|---|---|
| info | Синяя тема |
| alert | Желто-оранжевая тема |
| party | Фиолетовый градиент |
| пусто/по умолчанию | По умолчанию info |
| Тест | Шаги | Ожидаемое |
|---|---|---|
| Разместить блок | Макет блока | Видим в регионе |
| Нет активных промо | Все промо истекли/отключены | Блок отображается пустым |
| Несколько промо | Создать 3 активных промо | Все 3 отображаются |
| Инвалидация кеша | Редактировать промо | Блок обновляется немедленно |
| Кеш на основе времени | Ждать истечения промо | Блок обновляется после истечения кеша |
Модуль реализует сложную стратегию кеширования для контента, основанного на времени:
public function getCacheTags(): array {
return Cache::mergeTags(parent::getCacheTags(), ['promo_kit_promo_list']);
}Тег promo_kit_promo_list автоматически инвалидируется когда:
Это гарантирует, что блок обновляется немедленно при изменении контента.
public function getCacheContexts(): array {
return Cache::mergeContexts(parent::getCacheContexts(), ['user.permissions']);
}Контекст user.permissions гарантирует, что разные пользователи видят соответствующий контент на основе их разрешений доступа.
public function getCacheMaxAge(): int {
return $this->promoManager->getSecondsUntilNextBoundary();
}Вместо фиксированной продолжительности кеша (например, 1 час), блок вычисляет, когда следующее промо начнется или закончится, и устанавливает истечение кеша на этот точный момент.
Пример:
Это гарантирует, что промо появляются/исчезают в правильное время без избыточного или недостаточного кеширования.
Echo Flow помогает канадским компаниям с Drupal-разработкой корпоративного уровня.
components/promo_banner/promo_banner.component.ymlcomponents/promo_banner/promo_banner.twigcomponents/promo_banner/promo_banner.cssddev drush crpromo-kit-promo.html.twigtemplates/ddev drush crgetActivePromos()ddev drush cr
ddev drush crtype: ['entity:promo_kit_promo']/admin/people/permissionsddev drush crpromo_kit_promoEditorialContentEntityBasegetActivePromos(), getSecondsUntilNextBoundary()promo_banner_blockPromoDateRangeConstraintPromoDateRangeConstraintValidatorPromoHooksentity_presave с атрибутом #[Hook]promo_bannerPromo Kit использует пользовательскую сущность потому что:
Мы построили Promo Kit, используя современные лучшие практики Drupal 11.2:
promo_kit_promo)Эта архитектура готова к продакшену, поддерживаема и следует соглашениям Drupal 11.2. Модуль демонстрирует, как правильно строить пользовательские сущности — с правильным разделением ответственности, тестируемым кодом и отличной производительностью.
web/modules/custom/promo_kit/src/PromoAccessControlHandler.php
Обработчик контроля доступа управляет разрешениями для сущностей промо:
view promo_kit_promo (и сущность должна быть опубликована)create promo_kit_promoedit promo_kit_promodelete promo_kit_promoadminister promo_kit_promo обходит все проверкиweb/modules/custom/promo_kit/src/PromoListBuilder.php
Предоставляет страницу админ списка по адресу /admin/content/promo:
PromoForm (src/Form/PromoForm.php)
PromoSettingsForm (src/Form/PromoSettingsForm.php)
/admin/structure/promo-kit-promoweb/modules/custom/promo_kit/src/PromoViewBuilder.php
Отрисовывает сущности промо:
В promo_kit.module мы обрабатываем операции с учетными записями пользователей:
hook_user_cancel()
user_cancel_block_unpublish: Снимает с публикации промо пользователяuser_cancel_reassign: Переназначает промо анонимному пользователюhook_user_predelete()
Это обеспечивает целостность данных при удалении учетных записей пользователей.
Если вы добавляете поля через Field UI (не базовые поля), экспортируйте их:
ddev drush cex -yСкопируйте соответствующие 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/Модуль в настоящее время поставляется с минимальной конфигурацией (только конфигурации действий для массовых операций).
Базовые поля (что мы используем):
baseFieldDefinitions())Конфигурационные поля (альтернатива):
Promo Kit использует базовые поля потому что:
✅ CRUD сущности
/admin/content/promo✅ Логика дат
✅ Валидация
✅ Отображение блока
✅ Кеширование
✅ Разрешения
Для продакшн модулей рассмотрите добавление:
Юнит тесты
Kernel тесты
Функциональные тесты
Пример структуры тестов:
tests/
src/
Unit/
PromoManagerTest.php
Kernel/
PromoEntityTest.php
Functional/
PromoBlockTest.phpPromoManager использует запросы сущностей с условиями, не загружая все сущности:
✅ Хорошо (что мы делаем):
$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
});Блок реализует три измерения кеширования:
Это обеспечивает:
Сущность автоматически получает индексы на:
id (первичный ключ)uuid (уникальный)revision_idlangcodestatusДля высоконагруженных сайтов с множеством промо рассмотрите добавление пользовательских индексов на:
promo_date_range.value (дата начала)promo_date_range.end_value (дата окончания)Это можно сделать в hook_schema_alter() или хуке обновления.
Для контроля порядка отображения:
Promo::baseFieldDefinitions():$fields['weight'] = BaseFieldDefinition::create('integer')
->setLabel(t('Вес'))
->setDescription(t('Меньшие веса появляются первыми.'))
->setDefaultValue(0)
->setDisplayOptions('form', [
'type' => 'number',
'weight' => 20,
]);$query->sort('weight', 'ASC');Для категоризации промо:
$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Для поддержки различных стилей отображения:
/admin/structure/display-modes/view/add/promo_kit_promo/admin/structure/promo-kit-promo/display/[view_mode]$build['#items'][] = $view_builder->view($promo, 'teaser');Для предоставления промо через REST:
promo_kit.info.yml:dependencies:
- rest:restddev drush en rest -ypromo_kit_promo/promo_kit_promo/{id}?_format=json✅ Тестировать в staging окружении ✅ Убедиться, что все промо отображаются правильно ✅ Тестировать видимость на основе дат ✅ Тестировать ограничения валидации ✅ Тестировать разрешения для всех ролей ✅ Проверить поведение кеширования ✅ Проверить производительность с реалистичным объемом данных
drush en promo_kit -ydrush crdrush cim -ydrush updb -ydrush role:perm:list authenticated/admin/structure/blockdrush block:place promo_banner_block✅ Убедиться, что блок появляется на фронтенде ✅ Создать тестовое промо и убедиться, что оно отображается ✅ Проверить логи ошибок на наличие проблем ✅ Мониторить производительность ✅ Обучить редакторов контента созданию промо
accessCheck()❌ Ошибка:
$query = $storage->getQuery();
$ids = $query->execute(); // Фатальная ошибка в D11✅ Решение:
$query = $storage->getQuery();
$query->accessCheck(TRUE); // или FALSE для внутренних запросов
$ids = $query->execute();❌ Проблема: Даты не соответствуют ожидаемому поведению
✅ Решение: Всегда используйте UTC для сравнения дат:
$now = new DrupalDateTime('now', 'UTC');❌ Проблема: Блок показывает устаревшие данные после редактирования промо
✅ Решение: Убедитесь, что теги кеша правильные:
public function getCacheTags(): array {
return Cache::mergeTags(parent::getCacheTags(), ['promo_kit_promo_list']);
}❌ Проблема: Ограничение не срабатывает
✅ Решение: Очистите кеш дважды (обнаружение плагинов):
drush cr && drush cr❌ Проблема: Ошибка “Template not found”
✅ Решение: Проверьте, что имя файла соответствует тематическому хуку:
promo_kit_promopromo-kit-promo.html.twig (подчеркивания становятся дефисами)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#[ContentEntityType(
id: 'promo_kit_promo',
label: new TranslatableMarkup('Промо-баннер'),
// ...
)]
class Promo extends EditorialContentEntityBase { }#[Hook('entity_presave')]
public function validateDates(EntityInterface $entity): void { }#[Constraint(
id: 'PromoDateRange',
label: new TranslatableMarkup('Диапазон дат промо'),
)]
class PromoDateRangeConstraint extends SymfonyConstraint { }public function __construct(
private readonly EntityTypeManagerInterface $entityTypeManager,
) {}public string $message = 'Дата окончания не может быть раньше даты начала.';protected readonly PromoManager $promoManager;public function validate(mixed $entity, Constraint $constraint): void { }Эти паттерны — все функции PHP 8.3+, которые Drupal 11.2 полностью принимает.
Конец руководства
Теперь у вас есть полный, готовый к продакшену модуль Drupal 11.2, который демонстрирует современные практики разработки. Модуль Promo Kit демонстрирует пользовательские сущности, слои сервисов, валидацию, кеширование, OO хуки и SDC тематизацию — все инструменты, необходимые для создания сложных приложений Drupal.