Валидация в Symfony: разбор constraints и типичных ошибок
Как работают ограничения валидатора в Symfony 7.x, где новички теряют данные, почему валидация в контроллере не срабатывает и чем платить за кастомные constraints.

Форма отправляет данные, а $validator->validate($user) возвращает пустой список ошибок, хотя email явно кривой. Или наоборот: валидация срабатывает на entity, но данные всё равно уходят в базу битыми. Оба случая — про то, что компонент Validator в Symfony устроен не так, как ждёт человек, пришедший из фреймворка с валидацией «в одном месте». Разбираемся, как читаются constraints, где живёт группа валидации и почему один и тот же атрибут ведёт себя по-разному в форме и в DTO. Версии: Symfony 7.x, PHP 8.2+.
Как валидатор вообще находит ограничения
Компонент symfony/validator не проверяет объект сам по себе. Он берёт метаданные — список constraints, привязанных к классу и его свойствам, — и прогоняет по ним значения. Метаданные можно задать четырьмя способами: PHP-атрибутами, YAML, XML и вызовами в статическом методе loadValidatorMetadata(). По умолчанию в Symfony 7 включены атрибуты.
Ключевой момент, на котором спотыкаются: валидатор ничего не запускает автоматически. Формы (symfony/form) вызывают его сами при $form->handleRequest(). Если вы работаете с DTO без формы, валидацию нужно вызвать руками.
use Symfony\Component\Validator\Constraints as Assert;
class RegistrationDto
{
#[Assert\NotBlank(message: 'Укажите email')]
#[Assert\Email(message: 'Некорректный email')]
public string $email = '';
#[Assert\Length(min: 8, minMessage: 'Пароль от 8 символов')]
public string $password = '';
}
И вызов в контроллере — валидатор внедряется через autowiring по интерфейсу ValidatorInterface:
use Symfony\Component\Validator\Validator\ValidatorInterface;
public function register(
Request $request,
ValidatorInterface $validator
): Response {
$dto = new RegistrationDto();
$dto->email = $request->request->get('email', '');
$dto->password = $request->request->get('password', '');
$errors = $validator->validate($dto);
if (count($errors) > 0) {
// $errors — ConstraintViolationListInterface, итерируется
return $this->json(['errors' => (string) $errors], 422);
}
// данные валидны, работаем дальше
}
Если этот вызов не написать — валидация просто не произойдёт. Никаких исключений, никаких предупреждений. Объект уедет дальше как есть. Это первая причина «валидация не работает».
Пустой результат при кривых данных: три причины
Валидатор вернул пустой список, хотя данные явно невалидны. Порядок проверки:
- Атрибут не читается. Убедитесь, что импортирован именно
Symfony\Component\Validator\Constraints as Assert, а не какой-то другой namespace. IDE иногда подставляет constraints из Doctrine или из вашего кода. - Свойство приватное без нужной видимости для валидатора. Валидатор читает публичные свойства и геттеры. Для приватного свойства без геттера ограничение на свойстве сработает через рефлексию, а вот
#[Assert\Callback]на методе — только если метод доступен. - Валидация ушла в другую группу. Если у constraint указана группа, а вы вызываете
validate()без неё, ограничение не проверится. Это самая коварная причина.
Группы валидации
Каждый constraint по умолчанию попадает в группу Default. Вызов $validator->validate($dto) без второго аргумента проверяет только Default. Если вы написали так:
#[Assert\NotBlank(groups: ['strict'])]
public string $phone = '';
то это ограничение не сработает при обычном вызове. Нужно передать группу явно:
$errors = $validator->validate($dto, null, ['strict']);
Группы полезны, когда одна и та же entity валидируется по-разному при создании и при обновлении. Но именно тут теряют ограничения: добавили группу «на будущее», забыли передать её в вызов — и проверка молча отключилась.
Если constraint не срабатывает, а атрибут выглядит правильно — первым делом посмотрите на его группу и на группу в вызове validate. В девяти случаях из десяти дело в этом.
Встроенные constraints, которые путают
Не все ограничения делают то, что подсказывает название.
| Constraint | Что проверяет | Частая ошибка |
|---|---|---|
NotBlank | Значение не пустая строка, не null, не пустой массив | Путают с NotNull: строка "0" проходит NotBlank, но по умолчанию нет — опция allowNull |
NotNull | Только что значение не null | Пустая строка проходит проверку |
Valid | Запускает валидацию вложенного объекта | Без него constraints на вложенном DTO не проверяются |
Type | Тип значения | Для скалярных DTO с типизированными свойствами часто избыточен |
Choice | Значение входит в список | Для массива значений нужна опция multiple: true |
Отдельно про #[Assert\Valid]: если DTO содержит другой объект или коллекцию объектов со своими ограничениями, валидатор не заглянет внутрь без этого атрибута. Забытый Valid — вторая по частоте причина «внутренние поля не проверяются».
class OrderDto
{
#[Assert\Valid]
public AddressDto $address;
/** @var ItemDto[] */
#[Assert\Valid]
public array $items = [];
}
Кастомный constraint: когда и чем платить
Встроенных ограничений хватает для большинства случаев. Кастомный constraint нужен, когда правило нельзя выразить композицией существующих или когда логика зависит от внешних данных — например, проверка уникальности по базе.
Constraint состоит из двух классов: сам constraint (хранит опции и сообщения) и validator (содержит логику). Это цена: одна проверка — два класса плюс регистрация валидатора как сервиса.
use Symfony\Component\Validator\Constraint;
#[\Attribute]
class ContainsLatinOnly extends Constraint
{
public string $message = 'Значение "{{ value }}" должно содержать только латиницу.';
}
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
class ContainsLatinOnlyValidator extends ConstraintValidator
{
public function validate($value, Constraint $constraint): void
{
if (!$constraint instanceof ContainsLatinOnly) {
throw new \Symfony\Component\Validator\Exception\UnexpectedTypeException(
$constraint, ContainsLatinOnly::class
);
}
// null и пустую строку не трогаем — это дело NotBlank
if (null === $value || '' === $value) {
return;
}
if (!preg_match('/^[a-zA-Z]+$/', (string) $value)) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ value }}', (string) $value)
->addViolation();
}
}
}
Правило хорошего тона: кастомный валидатор не проверяет «пусто ли значение». За это отвечает NotBlank/NotNull. Если ваш валидатор ругается на пустую строку, вы дублируете логику и получаете два сообщения об одной проблеме.
Для проверок с обращением к базе валидатор внедряет зависимости через конструктор — Symfony автоматически регистрирует классы с суффиксом Validator как сервисы при включённом autoconfigure.
Где всё это ломается
- Валидация в форме не совпадает с валидацией DTO. Форма может добавлять свои ограничения через
constraintsв опциях поля. Тогда одно правило живёт в форме, другое на DTO, и при отладке вы смотрите не туда. - Регулярка в кастомном constraint съедает производительность. Валидатор вызывается на каждый элемент коллекции. Тяжёлая проверка на списке из сотен элементов заметно тормозит запрос — измеряйте, если валидируете большие массивы.
- Проверка уникальности через кастомный constraint не защищает от гонки. Между валидацией и вставкой другой запрос может занять то же значение. Валидация тут — про UX, а гарантию даёт уникальный индекс в базе.
- Cascade без
Valid. Вложенные объекты молча пропускаются. Проверяется только верхний уровень.
Если не сработало
count($errors) === 0при кривых данных — проверьте импорт namespace и группу валидации.- «This value should not be null» на непустом поле — скорее всего свойство не заполнилось из запроса, а не сама валидация врёт. Проверьте маппинг данных до вызова validate.
- Кастомный constraint не находит валидатор (
ValidatorException) — убедитесь, что имя класса валидатора это имя constraint плюсValidator, либо переопределитеvalidatedBy().
Что настроить дальше
Свяжите валидацию с сериализацией: связка #[MapRequestPayload] в Symfony 7 сама создаёт DTO из тела запроса и прогоняет валидатор, избавляя от ручного заполнения свойств. Дальше стоит посмотреть на группировку constraints по сценариям (создание против обновления) и на вывод ошибок в едином формате для API. Точные опции каждого встроенного constraint и их сообщения сверяйте с документацией компонента для вашей минорной версии — набор опций между релизами дополняется.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Symfony Validation — официальная документация, Symfony — Creating a Custom Validation Constraint
Частые вопросы
Валидатор возвращает пустой список ошибок, хотя данные явно невалидны. Что проверить первым?
У меня Symfony 6, а не 7. Код подойдёт?
Почему constraints на вложенном объекте не срабатывают?
Можно ли проверять уникальность значения в базе через constraint?
Чем NotBlank отличается от NotNull?
Валидация в форме и на DTO дают разный результат — почему?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.