Перейти к основному содержанию
Login

Строим будущее: Практическое руководство по разработке пользовательских сущностей в Drupal 11.2

Andrii Kocherhin profile picture
Автор: Andrii Kocherhin
Cyber Drupal 11

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)

bash
# MacOS / Linux (Homebrew)
brew install ddev/ddev/ddev

Создание проекта Drupal 11

bash
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 -y

2. Модуль Promo Kit: Каркас

Генерируем модуль:

bash
ddev drush generate module

Используйте подсказки типа:

  • Имя модуля: Promo Kit
  • Машинное имя: promo_kit
  • Описание: Управляет промо-баннерами с интеграцией SDC.
  • Зависимости: datetime_range, link, file
  • Создать файл .module? Да (мы будем использовать его для тематических хуков)
  • Создать файл install? Да (опционально)

Редактируем web/modules/custom/promo_kit/promo_kit.info.yml:

yaml
name:'Promo Kit'
type: module
description:'Управляет промо-баннерами с интеграцией SDC.'
package: Custom
core_version_requirement: ^10 || ^11
dependencies:
- datetime_range:datetime_range
- file:file
- link:link

3. Пользовательская контентная сущность: Промо-баннер

Создаем класс сущности:

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 для конфигурации:

php
#[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):

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 на основе того, когда следующее промо начнется или закончится:

php
public function getSecondsUntilNextBoundary(): int {
  // Найти ближайшую будущую дату начала/окончания
  // Вернуть секунды до этой границы
  // По умолчанию: 3600 (1 час)
}

Это гарантирует, что кеш блока истекает в нужное время, когда изменяется видимость промо.

Регистрируем сервис в promo_kit.services.yml:

yaml
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: Динамический на основе следующей границы
php
32 lines
#[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

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

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

php
21 lines
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 ловит любые программные сохранения, которые обходят валидацию формы.

После добавления классов хуков очистите кеш:

bash
ddev drush cr

8. Фронтенд: Single Directory Components (SDC)

Создаем структуру компонента:

bash
mkdir -p web/modules/custom/promo_kit/components/promo_banner

8.1 Определение компонента

web/modules/custom/promo_kit/components/promo_banner/promo_banner.component.yml

yaml
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

plaintext
<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">Посмотреть &rarr;</a>
    {% endif %}
  </div>
</div>

8.3 Стили компонента

web/modules/custom/promo_kit/components/promo_banner/promo_banner.css

css
91 lines
.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:

php
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

plaintext
31 lines
{#
/**
 * @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

yaml
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

yaml
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. Включение и использование

Включаем модуль:

bash
ddev drush en promo_kit -y
ddev drush cr

11.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 Теги кеша

php
public function getCacheTags(): array {
  return Cache::mergeTags(parent::getCacheTags(), ['promo_kit_promo_list']);
}

Тег promo_kit_promo_list автоматически инвалидируется когда:

  • Создается любое промо
  • Обновляется любое промо
  • Удаляется любое промо

Это гарантирует, что блок обновляется немедленно при изменении контента.

13.2 Контексты кеша

php
public function getCacheContexts(): array {
  return Cache::mergeContexts(parent::getCacheContexts(), ['user.permissions']);
}

Контекст user.permissions гарантирует, что разные пользователи видят соответствующий контент на основе их разрешений доступа.

13.3 Динамический Cache Max-Age

php
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”

  1. Подтвердите существование файлов компонента:
    • components/promo_banner/promo_banner.component.yml
    • components/promo_banner/promo_banner.twig
    • components/promo_banner/promo_banner.css
  2. Очистите кеш:
bash
ddev drush cr
  1. Проверьте имя файла шаблона:
    • Должно быть: promo-kit-promo.html.twig
    • Расположение: директория templates/

“Блок показывает истекшие промо”

  1. Проверьте диапазоны дат промо в админ UI
  2. Убедитесь, что часовой пояс сервера соответствует ожидаемому часовому поясу
  3. Очистите кеш:
bash
ddev drush cr
  1. Проверьте логику запроса PromoManager в getActivePromos()

“Валидация не срабатывает”

  1. Подтвердите существование файлов ограничений и правильность их пространств имен
  2. Очистите кеш дважды (обнаружение плагинов может быть липким):
bash
ddev drush cr
ddev drush cr
  1. Проверьте тип сущности в аннотации ограничения: type: ['entity:promo_kit_promo']

“Ошибки отказа в доступе”

  1. Предоставьте соответствующие разрешения:
    • /admin/people/permissions
    • Найдите “promo”
    • Предоставьте разрешения соответствующим ролям
  2. Очистите кеш:
bash
ddev drush cr

15. Что мы построили (Сводка архитектуры)

Пользовательская контентная сущность

  • 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:

  1. Пользовательская контентная сущность с правильным префиксом (promo_kit_promo)
  2. Слой сервисов (PromoManager) для бизнес-логики
  3. Фильтрация на основе запросов (масштабируемая, не загружающая все сущности)
  4. Плагин блока с правильным внедрением зависимостей
  5. Сложное кеширование (теги, контексты, динамический max-age)
  6. Ограничения валидации (правильный UX)
  7. OO хуки (современный подход на основе атрибутов)
  8. 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 (не базовые поля), экспортируйте их:

bash
ddev drush cex -y

18.2 Поставка конфигурации по умолчанию

Скопируйте соответствующие YAML файлы из config/sync/ в config/install/:

bash
# Пример: Если вы добавили поле через 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 операции сущности
  • Тестировать логику запросов
  • Тестировать контроль доступа

Функциональные тесты

  • Тестировать отправку форм
  • Тестировать отображение блока
  • Тестировать разрешения

Пример структуры тестов:

plaintext
tests/
  src/
    Unit/
      PromoManagerTest.php
    Kernel/
      PromoEntityTest.php
    Functional/
      PromoBlockTest.php

20. Соображения производительности

20.1 Оптимизация запросов

PromoManager использует запросы сущностей с условиями, не загружая все сущности:

Хорошо (что мы делаем):

php
$query->condition('status', 1)
      ->condition('promo_date_range.value', $now, '<=');
$ids = $query->execute();
$promos = $storage->loadMultiple($ids);

Плохо (не делайте так):

php
$all_promos = $storage->loadMultiple();
$active = array_filter($all_promos, function($promo) {
  // Фильтрация в PHP
});

20.2 Стратегия кеширования

Блок реализует три измерения кеширования:

  1. Теги кеша: Инвалидировать при изменении контента
  2. Контексты кеша: Варьировать по разрешениям пользователя
  3. Cache Max-Age: Истекать на следующей границе

Это обеспечивает:

  • Свежий контент при редактировании промо
  • Правильный контент для разных пользователей
  • Своевременные обновления при истечении/активации промо

20.3 Индексы базы данных

Сущность автоматически получает индексы на:

  • id (первичный ключ)
  • uuid (уникальный)
  • revision_id
  • langcode
  • status

Для высоконагруженных сайтов с множеством промо рассмотрите добавление пользовательских индексов на:

  • promo_date_range.value (дата начала)
  • promo_date_range.end_value (дата окончания)

Это можно сделать в hook_schema_alter() или хуке обновления.


21. Расширение модуля

21.1 Добавление поля веса

Для контроля порядка отображения:

  1. Добавить базовое поле в Promo::baseFieldDefinitions():
php
$fields['weight'] = BaseFieldDefinition::create('integer')
  ->setLabel(t('Вес'))
  ->setDescription(t('Меньшие веса появляются первыми.'))
  ->setDefaultValue(0)
  ->setDisplayOptions('form', [
    'type' => 'number',
    'weight' => 20,
  ]);
  1. Обновить запрос PromoManager:
php
$query->sort('weight', 'ASC');

21.2 Добавление категорий/тегов

Для категоризации промо:

  1. Добавить поле ссылки на сущность:
php
$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'],
  ]);
  1. Создать словарь таксономии promo_categories
  2. Обновить блок для фильтрации по категории (добавить конфигурацию блока)

21.3 Добавление режимов просмотра

Для поддержки различных стилей отображения:

  1. Создать режим просмотра через UI или конфигурацию:
    • /admin/structure/display-modes/view/add/promo_kit_promo
  2. Настроить поля для режима просмотра:
    • /admin/structure/promo-kit-promo/display/[view_mode]
  3. Использовать в блоке:
php
$build['#items'][] = $view_builder->view($promo, 'teaser');

21.4 Добавление поддержки REST API

Для предоставления промо через REST:

  1. Добавить зависимость в promo_kit.info.yml:
yaml
dependencies:
- rest:rest
  1. Включить REST ресурс:
bash
ddev drush en rest -y
  1. Настроить REST ресурс для типа сущности promo_kit_promo
  2. Доступ через: /promo_kit_promo/{id}?_format=json

22. Чек-лист развертывания

22.1 Перед развертыванием

✅ Тестировать в staging окружении ✅ Убедиться, что все промо отображаются правильно ✅ Тестировать видимость на основе дат ✅ Тестировать ограничения валидации ✅ Тестировать разрешения для всех ролей ✅ Проверить поведение кеширования ✅ Проверить производительность с реалистичным объемом данных

22.2 Шаги развертывания

  1. Включить модуль:
bash
drush en promo_kit -y
  1. Очистить кеш:
bash
drush cr
  1. Импортировать конфигурацию (если используется управление конфигурацией):
bash
drush cim -y
  1. Запустить хуки обновления (если есть):
bash
drush updb -y
  1. Проверить разрешения:
bash
drush role:perm:list authenticated
  1. Разместить блок (если не в конфигурации):
    • Через UI: /admin/structure/block
    • Или через Drush: drush block:place promo_banner_block

22.3 После развертывания

✅ Убедиться, что блок появляется на фронтенде ✅ Создать тестовое промо и убедиться, что оно отображается ✅ Проверить логи ошибок на наличие проблем ✅ Мониторить производительность ✅ Обучить редакторов контента созданию промо


23. Распространенные подводные камни и решения

Подводный камень 1: Забывание accessCheck()

Ошибка:

php
$query = $storage->getQuery();
$ids = $query->execute(); // Фатальная ошибка в D11

Решение:

php
$query = $storage->getQuery();
$query->accessCheck(TRUE); // или FALSE для внутренних запросов
$ids = $query->execute();

Подводный камень 2: Проблемы с часовыми поясами

Проблема: Даты не соответствуют ожидаемому поведению

Решение: Всегда используйте UTC для сравнения дат:

php
$now = new DrupalDateTime('now', 'UTC');

Подводный камень 3: Кеш не инвалидируется

Проблема: Блок показывает устаревшие данные после редактирования промо

Решение: Убедитесь, что теги кеша правильные:

php
public function getCacheTags(): array {
  return Cache::mergeTags(parent::getCacheTags(), ['promo_kit_promo_list']);
}

Подводный камень 4: Валидация не работает

Проблема: Ограничение не срабатывает

Решение: Очистите кеш дважды (обнаружение плагинов):

bash
drush cr && drush cr

Подводный камень 5: Шаблон не найден

Проблема: Ошибка “Template not found”

Решение: Проверьте, что имя файла соответствует тематическому хуку:

  • Хук: promo_kit_promo
  • Шаблон: promo-kit-promo.html.twig (подчеркивания становятся дефисами)

24. Ресурсы и дополнительное чтение

Официальная документация Drupal

Специфично для Drupal 11

Инструменты разработки


Приложение A: Полная структура файлов

plaintext
45 lines
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 для определения сущности

php
#[ContentEntityType(
  id: 'promo_kit_promo',
  label: new TranslatableMarkup('Промо-баннер'),
  // ...
)]
class Promo extends EditorialContentEntityBase { }

2. OO хуки с атрибутами

php
#[Hook('entity_presave')]
public function validateDates(EntityInterface $entity): void { }

3. Ограничения валидации

php
#[Constraint(
  id: 'PromoDateRange',
  label: new TranslatableMarkup('Диапазон дат промо'),
)]
class PromoDateRangeConstraint extends SymfonyConstraint { }

4. Продвижение свойств конструктора

php
public function __construct(
  private readonly EntityTypeManagerInterface $entityTypeManager,
) {}

5. Типизированные свойства

php
public string $message = 'Дата окончания не может быть раньше даты начала.';

6. Readonly свойства

php
protected readonly PromoManager $promoManager;

7. Подсказки типов Mixed

php
public function validate(mixed $entity, Constraint $constraint): void { }

Эти паттерны — все функции PHP 8.3+, которые Drupal 11.2 полностью принимает.


Конец руководства

Теперь у вас есть полный, готовый к продакшену модуль Drupal 11.2, который демонстрирует современные практики разработки. Модуль Promo Kit демонстрирует пользовательские сущности, слои сервисов, валидацию, кеширование, OO хуки и SDC тематизацию — все инструменты, необходимые для создания сложных приложений Drupal.