При разработке на Drupal рутинная необходимость это — добавление кастомной валидации для полей внутри сущностей. Стандартные средства Drupal (например, валидация через hook_form_alter) не всегда подходят, особенно когда нужно, чтобы валидация работала при любом способе сохранения сущности — через форму, REST API, JSON:API или миграции. Кроме того, в hook_form_alter сложно добраться до полей вложенных сущностей, как например, параграфы, чтобы подсветить ошибку именно там, где она произошла.
В этой статье мы рассмотрим, как добавить валидацию к полю внутри параграфа с использованием системы Constraints (ограничений) Drupal 11, например, чтобы она работала при редактировании node, в которой есть поле с параграфами.
В качестве примера мы проверим, что поле field_ticketland в параграфе типа afisha содержит корректный URL на сайт ticketland.ru.
Почему Constraints?
Constraints (ограничения) — это стандартный механизм валидации в Drupal, основанный на Symfony Validation Component. Его ключевые преимущества:
- Универсальность — работает при любом способе сохранения сущности
- Переиспользуемость — одно правило можно применить к разным полям
- Интеграция с формами — ошибки отображаются рядом с проблемным полем
- Расширяемость — легко создавать свои правила валидации
Архитектура Constraints в Drupal
Система Constraints состоит из двух ключевых компонентов:
- Constraint (Ограничение) — описывает правило, хранит метаданные (ID, сообщение об ошибке, параметры)
- ConstraintValidator (Валидатор) — содержит логику проверки значения
Такое разделение позволяет:
- Использовать одно правило с разными валидаторами
- Кешировать метаданные и валидаторы как сервисы
- Внедрять зависимости в валидаторы через Dependency Injection
Пошаговая реализация
Шаг 1: Создание структуры файлов
В вашем модуле (в примере — cmb_tweaks) создайте следующую структуру:
|
1 2 3 4 5 6 7 8 9 10 |
modules/custom/cmb_tweaks/ ├── cmb_tweaks.module ├── src/ │ ├── Hook/ │ │ └── TicketLandHooks.php │ └── Plugin/ │ └── Validation/ │ └── Constraint/ │ ├── TicketLandConstraint.php │ └── TicketLandConstraintValidator.php |
У вас может быть уже существующий модуль, тогда cmb_tweaks.module не понадобится, разместите srс файлы в вашем модуле, но учтите пространство имен (namespace).
Шаг 2: Создание класса ограничения (Constraint)
Файл: src/Plugin/Validation/Constraint/TicketLandConstraint.php
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
<?php declare(strict_types=1); namespace Drupal\cmb_tweaks\Plugin\Validation\Constraint; use Symfony\Component\Validator\Constraint as SymfonyConstraint; /** * @Constraint( * id = "paragraph_ticketland_constraint", * label = @Translation("Проверка поля ticketland в параграфе", context = "Validation"), * ) */ final class TicketLandConstraint extends SymfonyConstraint { public string $message = 'Не верный формат данных.'; } |
Что здесь происходит:
- Аннотация
@Constraintрегистрирует наш класс как плагин id— уникальный идентификатор, по которому мы будем ссылаться на правилоlabel— человекочитаемое название для интерфейса- Свойство
$message— сообщение об ошибке, которое увидит пользователь
Шаг 3: Создание валидатора
Файл: src/Plugin/Validation/Constraint/TicketLandConstraintValidator.php
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
<?php declare(strict_types=1); namespace Drupal\cmb_tweaks\Plugin\Validation\Constraint; use Drupal\Core\Field\FieldItemListInterface; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; final class TicketLandConstraintValidator extends ConstraintValidator { /** * {@inheritdoc} */ public function validate(mixed $value, Constraint $constraint): void { // Желательно проверить тип входящего объекта. if (!$value instanceof FieldItemListInterface) { return; } // Поле пустое — пропускаем (если поле не обязательное). if ($value->isEmpty()) { return; } // Получаем строковое значение поля. $fieldValue = $value->getString(); // Проверяем формат URL. $pattern = '#https://www\.ticketland\.ru/iframe-direct-sale/\w+#is'; if (!preg_match($pattern, $fieldValue)) { $this->context->addViolation($constraint->message); } } } |
Важные моменты:
- В метод
validate()приходитFieldItemList(объект поля), а не просто значение - Используем
getString()для получения текстового значения addViolation()добавляет ошибку, которая будет отображена в форме
Шаг 4: Прикрепление констрейнта к полю
Файл: src/Hook/TicketLandHooks.php
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
<?php namespace Drupal\cmb_tweaks\Hook; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Hook\Attribute\Hook; /** * Hook implementations for ticket_land integration. */ class TicketLandHooks { /** * Implements hook_entity_bundle_field_info_alter(). */ #[Hook('entity_bundle_field_info_alter')] public function entityBundleFieldInfoAlter(&$fields, EntityTypeInterface $entity_type, $bundle): void { // Применяем только к нужному типу и бандлу параграфа. if ($entity_type->id() === 'paragraph' && $bundle === 'afisha') { // Проверяем, что поле существует. if (!empty($fields['field_ticketland'])) { // Добавляем наш констрейнт к полю. $fields['field_ticketland']->addConstraint('paragraph_ticketland_constraint', []); } } } } |
Здесь мы объявили хук через аннотацию Hook. Такой подход работает не для всех хуков. И для такой реализации entity_bundle_field_info_alter требуется Drupal 11.1. Если у вас 10я версия, то просто реализуйте функциональное объявление этого хука.
Шаг 5: Очистка кеша
После создания всех файлов выполните очистку кеша:
|
1 |
drush cr |
Теперь валидация будет работать при сохранении любого параграфа типа afisha с полем field_ticketland.
Альтернативный подход: Callback валидация
Нельзя не упомянуть об альтернативе. Создавать два класса для небольшой скромной проверки поля — это всегда мне казалось каким то экстремальным программированием, есть альтернативный способ.
Мы также создаём хук hook_entity_bundle_field_info_alter, но ограничение (валидацию) реализуем через callback.
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 |
<?php namespace Drupal\cmb_tweaks\Hook; use Drupal\Core\Hook\Attribute\Hook; use Symfony\Component\Validator\Context\ExecutionContextInterface; /** * Hook implementations for ticket_land integration. */ class TicketLandHooks { /** * Implements hook_entity_bundle_field_info_alter(). */ #[Hook('entity_bundle_field_info_alter')] public function entityBundleFieldInfoAlter(&$fields, \Drupal\Core\Entity\EntityTypeInterface $entity_type, $bundle): void { // Проверяем, что мы работаем с нужным типом и бандлом сущности. if ($entity_type->id() == 'paragraph' && $bundle == 'afisha') { if (!empty($fields['field_ticketland'])) { // Добавляем валидацию через кол-бек. $fields['field_ticketland']->addPropertyConstraints('value', [ 'Callback' => [ 'callback' => [self::class, 'validateTicketlandUrl'], ], ]); } } } /** * Callback валидации для поля ticketland. */ public static function validateTicketlandUrl(mixed $value, ExecutionContextInterface $context): void { $pattern = '#https://www\.ticketland\.ru/iframe-direct-sale/\w+#is'; if (!preg_match($pattern, $value)) { $context->addViolation('Не верный формат данных.'); } } } |
Плюсы очевидны:
- Всё в одном месте — логика хука и валидации находится в одном классе
- Легко найти — разработчики знают, где искать валидацию
- Поддержка IDE — автодополнение, рефакторинг, навигация
Метод обязательно должен быть статическим (public static). Drupal вызывает callback через вызов функции, и статический метод — это единственный способ обратиться к классу без создания экземпляра.