Валидация
Валидация в Winter декларативна: правила — это атрибуты на полях объекта, а
проверку включает #[Valid] на параметре метода. Фреймворк собирает все
нарушения за один проход и возвращает 422 с картой «поле → ошибки».
Что такое валидация и зачем
Валидация — проверка, что входные данные соответствуют ожиданиям, до того как они попадут в бизнес-логику.
Проблема. Клиенту нельзя доверять: он пришлёт пустое имя, кривой email,
отрицательное количество. Проверять это вручную — значит писать в начале каждого
метода стену из if, которая перемешивается с логикой и живёт отдельно от описания
данных. Хуже другое: ручные проверки почти всегда падают на первой ошибке, и
пользователь правит форму по одному полю за раз, отправляя её пять раз подряд.
Решение. Опишите правила атрибутами прямо там, где объявлены поля. Фреймворк
проверит их все, соберёт нарушения разом и вернёт 422 с картой «поле → список
ошибок». В метод объект попадёт уже проверенным — в теле обработчика проверять
нечего.
Проверка структуры идёт всегда
Не путайте два уровня. Что поле есть и что оно нужного типа, фреймворк проверяет
всегда, при разборе запроса, — и без всякой валидации ответит 400. Валидация
из этого раздела — про содержание: длину, диапазон, формат. Она включается
отдельно и отвечает 422. Про первый уровень — на странице
Запросы.
Как включить
Правила ставятся на параметры конструктора объекта, а проверку включает #[Valid]
на параметре метода:
<?php
namespace Main;
use Flytachi\Winter\Kernel\Http\Request\Validation\{Required, NotBlank, Size, Email, Min, Max};
class CreateUserDto
{
public function __construct(
#[Required] #[NotBlank] #[Size(2, 100)]
public readonly string $name,
#[Required] #[Email]
public readonly string $email,
#[Min(0)] #[Max(150)]
public readonly int $age = 0,
) {}
}#[PostMapping('users')]
public function create(#[Valid, RequestJson] CreateUserDto $dto): ResponseEntity
{
// сюда $dto попадёт только проверенным
return ResponseEntity::created($this->service->create($dto));
}Атрибуты складываются: на одном поле их может быть сколько угодно, проверятся все.
Без `#[Valid]` правила молчат
Ограничения на полях объекта — это только объявление. Если забыть #[Valid] на
параметре метода, объект соберётся и попадёт в обработчик без единой проверки,
и ошибки не будет никакой. Проверить легко: отправьте заведомо негодные данные и
убедитесь, что пришёл 422.
На скалярных параметрах — без #[Valid]
Отдельное исключение, о котором удобно знать: если ограничение стоит прямо на скалярном параметре метода, оно срабатывает само.
#[GetMapping]
public function index(#[RequestParam, Min(1)] int $page = 1): ResponseEntity
{
// ?page=0 → 422 {"page": ["must be at least 1"]}
}Так же ведут себя #[PathVariable], #[RequestHeader] и извлечение одного поля
через field:. #[Valid] нужен только там, где проверяемое — объект.
Формат ошибок
Провал возвращает 422 Unprocessable Entity с картой: ключ — имя поля, значение — список всех его нарушений.
{
"code": 422,
"message": "Validation failed",
"errors": {
"name": ["is required"],
"email": ["must be a valid email address"],
"age": ["must not exceed 150"]
}
}Ключи вложенных структур строятся по пути к полю:
| Что за поле | Ключ в ответе |
|---|---|
| Поле верхнего уровня | "email" |
| Поле вложенного объекта | "filter.minPrice" |
| Элемент коллекции | "items[1].name" |
| Элемент при массовой загрузке | "[0].title" |
Иногда ошибки приходят в два приёма
Ограничения на полях, которых в запросе не было, проверяются последним шагом — уже после того, как разобрана структура. Поэтому если в одном запросе есть и негодное значение, и пропущенное поле, первым ответом придёт только первое, а про пропуск клиент узнает со второй попытки.
Практический вывод: #[Required] на поле, которое клиент просто не прислал, не
смешивается в одном ответе с ошибкой формата в соседнем поле.
Справочник ограничений
Все живут в Flytachi\Winter\Kernel\Http\Request\Validation. У каждого есть
необязательный аргумент message — о нём ниже.
`null` пропускают все, кроме `#[Required]`
Ограничение, получившее null, считает проверку пройденной. Это сделано, чтобы
необязательное поле не пришлось помечать особым образом. Хотите запретить null —
добавьте #[Required].
Присутствие
| Ограничение | Проверяет | Сообщение по умолчанию |
|---|---|---|
#[Required] |
Значение не null |
is required |
#[NotBlank] |
Строка не пустая и не из одних пробелов | must not be blank |
Числа
| Ограничение | Проверяет | Сообщение по умолчанию |
|---|---|---|
#[Min(1)] |
Значение ≥ порога | must be at least 1 |
#[Max(150)] |
Значение ≤ порога | must not exceed 150 |
#[Positive] |
Больше нуля | must be positive |
#[PositiveOrZero] |
Ноль или больше | must be positive or zero |
#[Negative] |
Меньше нуля | must be negative |
#[NegativeOrZero] |
Ноль или меньше | must be negative or zero |
#[Digits(8, 2)] |
Не больше 8 цифр до точки и 2 после | integer part must not exceed 8 digits |
#[Min] и #[Max] принимают и целые, и дробные пороги.
Размер
| Форма | Проверяет | Сообщение по умолчанию |
|---|---|---|
#[Size(3)] |
Ровно 3 | must be exactly 3 |
#[Size(2, 255)] |
От 2 до 255 включительно | size must be between 2 and 255 |
Что именно меряется, зависит от типа значения:
| Тип | Чем меряется |
|---|---|
| Строка | Число символов, mb_strlen — кириллица считается верно |
| Массив | Число элементов |
| Число | Число знаков в записи: 100 → 3, -5 → 2 |
Форм только две — точная и диапазон. #[Size(2, 255)] эквивалентно
#[Size(min: 2, max: 255)], если предпочитаете именованные аргументы.
Строки и форматы
| Ограничение | Проверяет | Сообщение по умолчанию |
|---|---|---|
#[Email] |
Корректный адрес почты | must be a valid email address |
#[Url] |
Корректный URL | must be a valid URL |
#[Regex('/^[a-z-]+$/')] |
Совпадение с выражением | must match pattern … |
#[In(['draft', 'sent'])] |
Значение из списка | must be one of [draft, sent] |
#[Uuid] |
Корректный UUID любой версии | must be a valid UUID |
#[Uuid(4)] |
UUID указанной версии | must be a valid UUID v4 |
У #[In] есть второй аргумент strict (по умолчанию true) — сравнение с учётом
типа. Передайте strict: false, если строку "1" нужно считать равной числу 1.
Сеть и телефон
| Ограничение | Проверяет | Сообщение по умолчанию |
|---|---|---|
#[Ip] |
IP-адрес любой версии | must be a valid IP address |
#[Ipv4] |
Только IPv4 | must be a valid IPv4 address |
#[Ipv6] |
Только IPv6 | must be a valid IPv6 address |
#[Msisdn] |
Номер в E.164 без +: 7–15 цифр |
must be a valid MSISDN (7–15 digits, no + prefix) |
#[Phone] |
Номер с +, пробелами, дефисами, скобками |
must be a valid phone number |
#[Msisdn] строже: он для номера, который уйдёт в шлюз, поэтому допускает только
цифры. #[Phone] — для того, что ввёл человек.
Дата и время
| Ограничение | Проверяет | Сообщение по умолчанию |
|---|---|---|
#[Date] |
Дату в формате Y-m-d |
must be a valid date (Y-m-d) |
#[Date('d.m.Y')] |
Дату в своём формате | must be a valid date (d.m.Y) |
#[Time] |
Время H:i или H:i:s |
must be a valid time (H:i or H:i:s) |
#[Time('H:i')] |
Строго в указанном формате | — |
#[Datetime] |
Дату со временем в ISO 8601 | must be a valid datetime |
#[Datetime('Y-m-d H:i:s')] |
В своём формате | — |
Эти ограничения проверяют строку. Если поле объявлено типом
DateTimeImmutable, разбор происходит раньше, при привязке, и негодная дата даст
400 ещё до валидации.
Вложенные объекты и коллекции
Вложенный объект
Достаточно объявить поле типом другого объекта — проверка спускается в него сама,
отдельного #[Valid] на поле не требуется:
class FilterDto
{
public function __construct(
#[Min(0)] public readonly int $minPrice = 0,
#[Max(1_000_000)] public readonly int $maxPrice = 0,
) {}
}
class SearchDto
{
public function __construct(
#[NotBlank] public readonly string $query,
public readonly FilterDto $filter, // проверится вместе с родителем
) {}
}{"errors": {"filter.minPrice": ["must be at least 0"]}}Коллекция объектов
У массива нет типа элемента, поэтому его называют атрибутом #[ListOf]. Каждый
элемент разбирается и проверяется как обычный объект:
class OrderDto
{
public function __construct(
#[NotBlank] public readonly string $customer,
#[ListOf(ItemDto::class)]
public readonly array $items = [],
) {}
}{"errors": {"items[1].name": ["must not be blank"]}}Индекс в ключе показывает, какой именно элемент не прошёл, — при загрузке пачки из сотни позиций это единственное, что позволяет найти виноватого.
Массовая загрузка
Когда пачка объектов приходит корнем тела, вместо коллекции используется вариадик-параметр. Ключи ошибок тогда начинаются с индекса:
#[PostMapping('orders/bulk')]
public function bulk(#[Valid, RequestBody] OrderDto ...$orders): ResponseEntity {}
// {"errors": {"[0].customer": ["must not be blank"]}}Свои сообщения
Аргумент message есть у каждого ограничения и заменяет текст по умолчанию:
#[Size(2, 100, message: 'Имя должно быть от 2 до 100 символов')]
public readonly string $name,
#[Min(0, message: 'Количество не может быть отрицательным')]
public readonly int $qty,Перевод
Если сообщение целиком обёрнуто в фигурные скобки, оно считается ключом перевода и разрешается через локализацию:
#[Size(2, 100, message: '{order.name_length}')]
public readonly string $name,return [
'order' => [
'name_length' => 'Поле «:field»: от :min до :max символов',
],
];В подстановки попадает :field — имя поля — и любое публичное свойство самого
ограничения: :min, :max, :value, :format, :pattern, :values. Какие
именно доступны, видно по аргументам конструктора.
Ненайденный ключ возвращается как есть, без исключения, — опечатка в имени ключа проявится строкой в ответе, а не падением.
Своя проверка — #[Assert]
Когда готового ограничения не хватает, подключается собственная функция. Она
получает значение и имя поля, возвращает null при успехе или строку с ошибкой:
<?php
namespace Main;
class OrderRules
{
public static function multipleOf100(mixed $value, string $field): ?string
{
return is_int($value) && $value > 0 && $value % 100 === 0
? null
: 'must be a positive multiple of 100';
}
}use Flytachi\Winter\Kernel\Http\Request\Validation\Assert;
class CreateOrderDto
{
public function __construct(
#[Assert(OrderRules::class . '::multipleOf100')]
public readonly int $amount,
) {}
}Атрибут повторяем — на одно поле можно повесить несколько проверок, отработают все:
#[Assert(OrderRules::class . '::multipleOf100')]
#[Assert(OrderRules::class . '::withinCreditLimit')]
public readonly int $amount,Когда `#[Required]` действительно нужен
Поле, объявленное без null и без значения по умолчанию, обязательно само по себе:
если его нет в запросе, разбор структуры ответит is required без всяких
ограничений. #[Required] нужен для полей nullable или со значением по
умолчанию, которые вы всё-таки хотите требовать от клиента.
Своё ограничение — интерфейс Constraint
#[Assert] хорош для разовой проверки. Когда правило повторяется в нескольких DTO
или у него есть собственные параметры, лучше сделать свой атрибут — он ничем не
отличается от встроенных.
Нужен класс, реализующий Constraint: один метод, null при успехе, строка с
ошибкой при провале.
<?php
namespace Main\Validation;
use Attribute;
use Flytachi\Winter\Kernel\Http\Request\Validation\Constraint;
#[Attribute(Attribute::TARGET_PARAMETER)]
final readonly class DivisibleBy implements Constraint
{
public function __construct(
public int $step,
public ?string $message = null,
) {}
public function validate(mixed $value, string $field): ?string
{
if ($value === null) {
return null; // пустое поле — дело #[Required]
}
return is_int($value) && $value % $this->step === 0
? null
: $this->message ?? "must be divisible by {$this->step}";
}
}Дальше он используется как любой встроенный, вместе с ними и с собственным сообщением:
class CreateOrderDto
{
public function __construct(
#[Positive]
#[DivisibleBy(100)]
public readonly int $amount,
#[DivisibleBy(50, message: 'Вес указывают шагами по 50 грамм')]
public readonly int $weight,
) {}
}Три правила
Атрибут ставится на параметр. #[Attribute(Attribute::TARGET_PARAMETER)] — DTO
описывается параметрами конструктора, и проверки живут там же. Добавьте
Attribute::IS_REPEATABLE, если одно правило осмысленно вешать дважды.
null пропускайте. Отсутствующее значение — забота #[Required] и типа
свойства, а не ваша. Так встроены все ограничения: #[Size(2, 100)] на nullable-поле
молчит, пока значение не придёт. Без этой проверки ваше правило начнёт требовать
поле там, где оно необязательно.
Сообщение возвращается строкой. Она попадает в ответ 422 под именем поля.
Аргумент message для переопределения — соглашение всех встроенных ограничений,
и вашему стоит его придерживаться. Строку в фигурных скобках фреймворк считает
ключом перевода — см. Локализацию.
Что выбрать
#[Assert] |
Свой Constraint |
|
|---|---|---|
| Что писать | Статическую функцию | Класс-атрибут |
| Параметры правила | Нет — только имя callable | Любые, через конструктор |
| Переиспользование | Ссылкой на метод | Как встроенный атрибут |
| Когда брать | Разовая проверка в одном DTO | Правило, которое повторяется |
Разница только в оформлении: контракт один и тот же — (mixed $value, string $field): ?string, — и обе формы отрабатывают в общем проходе валидации.
Дальше
- Запросы и привязка параметров — откуда берётся объект и чем
400отличается от422 - Обработка ошибок — как исключение превращается в ответ
- Локализация — файлы переводов для ключей в сообщениях
- Ответы — что вернуть, когда проверка пройдена