Валидация
Валидация в Winter декларативна: правила — это атрибуты на полях DTO, а проверку
включает #[Valid] на параметре метода. Фреймворк собирает все нарушения
за один проход и возвращает 422 со структурированной картой ошибок.
Что такое валидация и зачем
Валидация — проверка, что входные данные соответствуют ожиданиям, до того как они попадут в бизнес-логику.
Проблема. Клиенту нельзя доверять: он пришлёт пустое имя, кривой email,
отрицательное количество. Проверять это вручную (if за if в начале метода) —
это шум, который перемешивается с логикой; к тому же ручные проверки обычно
падают на первой ошибке, и клиент правит форму по одному полю за раз.
Решение. Опишите правила атрибутами прямо на полях DTO. Фреймворк проверит все
поля, соберёт все ошибки сразу и вернёт 422 с понятной картой «поле →
ошибки». Контроллер получает уже валидный объект. Об этом и раздел.
Как включить
Правила-констрейнты ставятся на свойства конструктора DTO. Проверка запускается,
когда на параметре метода есть #[Valid]:
use Flytachi\Winter\K2\Http\Request\Validation\{Valid, Required, NotBlank, Min, Max, Email, Size};
class CreateUserDto
{
public function __construct(
#[Required] #[NotBlank] #[Size(min: 2, max: 100)]
public readonly string $name,
#[Required] #[Email]
public readonly string $email,
#[Min(0)] #[Max(150)]
public readonly int $age,
) {}
}
// В контроллере:
#[PostMapping('users')]
public function create(#[Valid] #[RequestJson] CreateUserDto $dto): ResponseEntity
{
// сюда $dto попадёт только валидным
}Формат ошибок
Провал валидации возвращает 422 Unprocessable Entity с картой ошибок — ключ это имя поля, значение — список сообщений (все нарушения по полю):
{
"code": 422,
"message": "Validation failed",
"errors": {
"name": ["is required"],
"email": ["must be a valid email address"],
"age": ["must be at least 0", "must not exceed 150"]
}
}Для вложенных DTO ключи используют точечную нотацию ("address.city"), для
коллекций — индексную ("items[0].name").
Констрейнты
Все живут в Flytachi\Winter\K2\Http\Request\Validation. Атрибуты складываются —
можно ставить несколько на одно поле, проверятся все.
Присутствие
| Констрейнт | Проверка |
|---|---|
#[Required] |
Не null (для nullable/defaulted-полей) |
#[NotBlank] |
Строка не пустая и не из одних пробелов (null проходит) |
Числа
| Констрейнт | Проверка |
|---|---|
#[Min(n)] |
Значение ≥ n |
#[Max(n)] |
Значение ≤ n |
#[Positive] / #[PositiveOrZero] |
> 0 / ≥ 0 |
#[Negative] / #[NegativeOrZero] |
< 0 / ≤ 0 |
#[Digits(integer: n, fraction: m)] |
≤ n цифр в целой части, ≤ m в дробной |
Длина / размер
| Констрейнт | Проверка |
|---|---|
#[Size(10)] |
Ровно 10 символов / элементов |
#[Size(min: 2, max: 100)] |
От 2 до 100 включительно |
Только две формы — точная Size(N) или полный диапазон Size(min, max). Меряет:
строки → mb_strlen, массивы → count, числа → число цифр.
Строки / формат
| Констрейнт | Проверка |
|---|---|
#[Email] |
Валидный email |
#[Url] |
Валидный URL |
#[Regex('/pattern/')] |
Полное совпадение с regex |
#[In(['a', 'b'])] |
Значение из списка (строгое сравнение) |
#[Uuid] / #[Uuid(4)] |
Валидный UUID / только v4 |
Сеть и телефон
| Констрейнт | Проверка |
|---|---|
#[Ip] / #[Ipv4] / #[Ipv6] |
Валидный IP / только IPv4 / только IPv6 |
#[Msisdn] |
MSISDN (E.164 без +): 7–15 цифр |
#[Phone] |
Телефон: +, цифры, пробелы, дефисы, скобки, 7–20 символов |
Дата и время
| Констрейнт | Проверка |
|---|---|
#[Date] / #[Date('d.m.Y')] |
Дата в Y-m-d / своём формате |
#[Time] / #[Time('H:i')] |
Время H:i/H:i:s / строго H:i |
#[Datetime] / #[Datetime('Y-m-d H:i:s')] |
ISO 8601 / свой формат |
Констрейнты пропускают null
Все констрейнты, кроме #[Required], не проверяют null — только не-null
значения. Чтобы отклонить и null, добавьте #[Required].
Вложенные DTO
#[Valid] на параметре контроллера каскадируется во все вложенные DTO
автоматически — отдельный #[Valid] на вложенном поле не нужен:
class FilterDto
{
public function __construct(
#[Min(0)] public readonly int $minPrice,
#[Max(1_000_000)] public readonly int $maxPrice,
) {}
}
class SearchDto
{
public function __construct(
#[NotBlank] public readonly string $query,
public readonly FilterDto $filter, // #[Valid] здесь не нужен
) {}
}
// Ошибка вложенного поля:
// {"errors": {"filter.minPrice": ["must be at least 0"]}}Кастомные сообщения
У каждого констрейнта есть необязательный message: — переопределяет дефолтный
текст:
#[Size(min: 0, max: 3, message: 'Имя слишком длинное')]
public readonly string $name,
#[Min(0, message: 'Количество не может быть отрицательным')]
public readonly int $qty,i18n — ключи перевода
Оберните ключ перевода в {...} — он разрешится через локализацию, с плейсхолдерами:
#[Size(min: 0, max: 3, message: '{order.name_too_long}')]
public readonly string $name,return [
'order' => [
'name_too_long' => 'Поле «:field»: длина не более :max символов',
],
];Доступны :field (имя поля) и любое публичное свойство констрейнта — :min,
:max, :value, :format, :pattern и т.д. Ненайденный ключ возвращается как
есть — исключения не будет.
Своя логика — #[Assert]
Когда встроенного констрейнта не хватает, подключите свой колбэк сигнатуры
fn(mixed $value, string $field): ?string (вернуть null — успех, строку —
ошибка). #[Assert] повторяем:
class OrderRules
{
public static function multipleOf100(mixed $value, string $field): ?string
{
return (is_int($value) && $value % 100 === 0)
? null
: 'must be a positive multiple of 100';
}
}
class CreateOrderDto
{
public function __construct(
#[Assert('App\Rules\OrderRules::multipleOf100')]
public readonly int $amount,
) {}
}#[Required] vs не-nullable
Не-nullable параметр PHP и так обязателен: если ключа нет в теле, гидратор сам
сообщит "is required". #[Required] нужен только для nullable или
defaulted полей, которые вы хотите сделать обязательными.
Дальше
- Запросы и привязка — откуда берётся DTO для валидации
- Обработка ошибок — как
ValidationException(422) превращается в ответ - Ответы — успешный ответ после валидации