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

Валидация

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

Триггер #[Valid]Ошибка 422 Unprocessable EntityКонстрейнты Request\Validation

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

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

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

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

Как включить

Правила-констрейнты ставятся на свойства конструктора DTO. Проверка запускается, когда на параметре метода есть #[Valid]:

php
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 с картой ошибок — ключ это имя поля, значение — список сообщений (все нарушения по полю):

json
{
"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] на вложенном поле не нужен:

php
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: — переопределяет дефолтный текст:

php
#[Size(min: 0, max: 3, message: 'Имя слишком длинное')]
public readonly string $name,

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

i18n — ключи перевода

Оберните ключ перевода в {...} — он разрешится через локализацию, с плейсхолдерами:

php
#[Size(min: 0, max: 3, message: '{order.name_too_long}')]
public readonly string $name,
lang/ru.php
return [
  'order' => [
      'name_too_long' => 'Поле «:field»: длина не более :max символов',
  ],
];

Доступны :field (имя поля) и любое публичное свойство констрейнта — :min, :max, :value, :format, :pattern и т.д. Ненайденный ключ возвращается как есть — исключения не будет.

Своя логика — #[Assert]

Когда встроенного констрейнта не хватает, подключите свой колбэк сигнатуры fn(mixed $value, string $field): ?string (вернуть null — успех, строку — ошибка). #[Assert] повторяем:

php
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 полей, которые вы хотите сделать обязательными.

Дальше