Основы веб-разработки

Валидация

Валидация в Winter декларативна: правила — это атрибуты на полях объекта, а проверку включает #[Valid] на параметре метода. Фреймворк собирает все нарушения за один проход и возвращает 422 с картой «поле → ошибки».

Включает #[Valid]Ограничения Http\Request\ValidationОшибка 422 Unprocessable Entity

Что такое валидация и зачем

Валидация — проверка, что входные данные соответствуют ожиданиям, до того как они попадут в бизнес-логику.

Проблема. Клиенту нельзя доверять: он пришлёт пустое имя, кривой email, отрицательное количество. Проверять это вручную — значит писать в начале каждого метода стену из if, которая перемешивается с логикой и живёт отдельно от описания данных. Хуже другое: ручные проверки почти всегда падают на первой ошибке, и пользователь правит форму по одному полю за раз, отправляя её пять раз подряд.

Решение. Опишите правила атрибутами прямо там, где объявлены поля. Фреймворк проверит их все, соберёт нарушения разом и вернёт 422 с картой «поле → список ошибок». В метод объект попадёт уже проверенным — в теле обработчика проверять нечего.

Проверка структуры идёт всегда

Не путайте два уровня. Что поле есть и что оно нужного типа, фреймворк проверяет всегда, при разборе запроса, — и без всякой валидации ответит 400. Валидация из этого раздела — про содержание: длину, диапазон, формат. Она включается отдельно и отвечает 422. Про первый уровень — на странице Запросы.

Как включить

Правила ставятся на параметры конструктора объекта, а проверку включает #[Valid] на параметре метода:

main/CreateUserDto.php
<?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,
  ) {}
}
php
#[PostMapping('users')]
public function create(#[Valid, RequestJson] CreateUserDto $dto): ResponseEntity
{
  // сюда $dto попадёт только проверенным
  return ResponseEntity::created($this->service->create($dto));
}

Атрибуты складываются: на одном поле их может быть сколько угодно, проверятся все.

Без `#[Valid]` правила молчат

Ограничения на полях объекта — это только объявление. Если забыть #[Valid] на параметре метода, объект соберётся и попадёт в обработчик без единой проверки, и ошибки не будет никакой. Проверить легко: отправьте заведомо негодные данные и убедитесь, что пришёл 422.

На скалярных параметрах — без #[Valid]

Отдельное исключение, о котором удобно знать: если ограничение стоит прямо на скалярном параметре метода, оно срабатывает само.

php
#[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 с картой: ключ — имя поля, значение — список всех его нарушений.

json
{
"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] на поле не требуется:

php
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,     // проверится вместе с родителем
  ) {}
}
json
{"errors": {"filter.minPrice": ["must be at least 0"]}}

Коллекция объектов

У массива нет типа элемента, поэтому его называют атрибутом #[ListOf]. Каждый элемент разбирается и проверяется как обычный объект:

php
class OrderDto
{
  public function __construct(
      #[NotBlank] public readonly string $customer,

      #[ListOf(ItemDto::class)]
      public readonly array $items = [],
  ) {}
}
json
{"errors": {"items[1].name": ["must not be blank"]}}

Индекс в ключе показывает, какой именно элемент не прошёл, — при загрузке пачки из сотни позиций это единственное, что позволяет найти виноватого.

Массовая загрузка

Когда пачка объектов приходит корнем тела, вместо коллекции используется вариадик-параметр. Ключи ошибок тогда начинаются с индекса:

php
#[PostMapping('orders/bulk')]
public function bulk(#[Valid, RequestBody] OrderDto ...$orders): ResponseEntity {}
// {"errors": {"[0].customer": ["must not be blank"]}}

Свои сообщения

Аргумент message есть у каждого ограничения и заменяет текст по умолчанию:

php
#[Size(2, 100, message: 'Имя должно быть от 2 до 100 символов')]
public readonly string $name,

#[Min(0, message: 'Количество не может быть отрицательным')]
public readonly int $qty,

Перевод

Если сообщение целиком обёрнуто в фигурные скобки, оно считается ключом перевода и разрешается через локализацию:

php
#[Size(2, 100, message: '{order.name_length}')]
public readonly string $name,
resources/lang/ru.php
return [
  'order' => [
      'name_length' => 'Поле «:field»: от :min до :max символов',
  ],
];

В подстановки попадает :field — имя поля — и любое публичное свойство самого ограничения: :min, :max, :value, :format, :pattern, :values. Какие именно доступны, видно по аргументам конструктора.

Ненайденный ключ возвращается как есть, без исключения, — опечатка в имени ключа проявится строкой в ответе, а не падением.

Своя проверка — #[Assert]

Когда готового ограничения не хватает, подключается собственная функция. Она получает значение и имя поля, возвращает null при успехе или строку с ошибкой:

main/OrderRules.php
<?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';
  }
}
php
use Flytachi\Winter\Kernel\Http\Request\Validation\Assert;

class CreateOrderDto
{
  public function __construct(
      #[Assert(OrderRules::class . '::multipleOf100')]
      public readonly int $amount,
  ) {}
}

Атрибут повторяем — на одно поле можно повесить несколько проверок, отработают все:

php
#[Assert(OrderRules::class . '::multipleOf100')]
#[Assert(OrderRules::class . '::withinCreditLimit')]
public readonly int $amount,

Когда `#[Required]` действительно нужен

Поле, объявленное без null и без значения по умолчанию, обязательно само по себе: если его нет в запросе, разбор структуры ответит is required без всяких ограничений. #[Required] нужен для полей nullable или со значением по умолчанию, которые вы всё-таки хотите требовать от клиента.

Своё ограничение — интерфейс Constraint

#[Assert] хорош для разовой проверки. Когда правило повторяется в нескольких DTO или у него есть собственные параметры, лучше сделать свой атрибут — он ничем не отличается от встроенных.

Нужен класс, реализующий Constraint: один метод, null при успехе, строка с ошибкой при провале.

main/Validation/DivisibleBy.php
<?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}";
  }
}

Дальше он используется как любой встроенный, вместе с ними и с собственным сообщением:

php
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, — и обе формы отрабатывают в общем проходе валидации.

Дальше