Web basics

Validation

Validation in Winter is declarative: the rules are attributes on an object’s fields, and #[Valid] on a method parameter switches the checking on. The framework collects every violation in one pass and answers 422 with a field-to-errors map.

Switched on by #[Valid]Constraints Http\Request\ValidationFailure 422 Unprocessable Entity

What validation is and why

Validation is checking that incoming data matches expectations before it reaches business logic.

The problem. The client cannot be trusted: it will send an empty name, a broken email, a negative quantity. Checking that by hand means a wall of ifs at the top of every method, mixed in with the logic and living far away from where the data is described. Worse, hand-written checks nearly always stop at the first error, so the user fixes the form one field at a time and submits it five times over.

The solution. Describe the rules as attributes right where the fields are declared. The framework checks them all, collects the violations at once and returns 422 with a field-to-error-list map. The object reaches your method already validated — there is nothing left to check in the handler.

Structural checking always happens

Do not confuse the two levels. That a field is present and of the right type is checked always, while the request is being parsed — and without any validation at all that answers 400. The validation on this page is about content: length, range, format. It is switched on separately and answers 422. The first level is covered under Requests.

Switching it on

Rules go on the object’s constructor parameters; #[Valid] on the method parameter turns the checking on:

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 only gets here validated
  return ResponseEntity::created($this->service->create($dto));
}

Attributes stack: a field may carry as many as you like and all of them run.

Without `#[Valid]` the rules stay silent

Constraints on an object’s fields are only a declaration. Forget #[Valid] on the method parameter and the object is assembled and handed to the handler with no checking at all, and there is no error to tell you. It is easy to verify: send deliberately invalid data and make sure a 422 comes back.

On scalar parameters — no #[Valid] needed

One exception worth knowing: a constraint placed directly on a scalar method parameter fires by itself.

php
#[GetMapping]
public function index(#[RequestParam, Min(1)] int $page = 1): ResponseEntity
{
  // ?page=0 → 422 {"page": ["must be at least 1"]}
}

#[PathVariable], #[RequestHeader] and single-field extraction via field: behave the same way. #[Valid] is only needed where the thing being checked is an object.

The error format

A failure returns 422 Unprocessable Entity with a map: the key is the field name, the value is the list of all its violations.

json
{
"code": 422,
"message": "Validation failed",
"errors": {
  "name":  ["is required"],
  "email": ["must be a valid email address"],
  "age":   ["must not exceed 150"]
}
}

Keys for nested structures are built from the path to the field:

The field Key in the response
A top-level field "email"
A field of a nested object "filter.minPrice"
An element of a collection "items[1].name"
An element in a bulk upload "[0].title"

Sometimes errors arrive in two rounds

Constraints on fields that were absent from the request are checked last, after the structure has been parsed. So if one request has both an invalid value and a missing field, the first response carries only the former, and the client learns about the missing one on the second attempt.

In practice: a #[Required] on a field the client simply did not send does not combine, in one response, with a format error on a neighbouring field.

Constraint reference

They all live in Flytachi\Winter\Kernel\Http\Request\Validation. Every one takes an optional message argument — covered below.

Everything passes `null`, except `#[Required]`

A constraint handed null considers the check passed. That is so an optional field does not need special marking. To forbid null, add #[Required].

Presence

Constraint Checks Default message
#[Required] The value is not null is required
#[NotBlank] A string is neither empty nor whitespace only must not be blank

Numbers

Constraint Checks Default message
#[Min(1)] Value ≥ the threshold must be at least 1
#[Max(150)] Value ≤ the threshold must not exceed 150
#[Positive] Greater than zero must be positive
#[PositiveOrZero] Zero or greater must be positive or zero
#[Negative] Less than zero must be negative
#[NegativeOrZero] Zero or less must be negative or zero
#[Digits(8, 2)] At most 8 digits before the point and 2 after integer part must not exceed 8 digits

#[Min] and #[Max] accept whole and fractional thresholds alike.

Size

Form Checks Default message
#[Size(3)] Exactly 3 must be exactly 3
#[Size(2, 255)] From 2 to 255 inclusive size must be between 2 and 255

What exactly is measured depends on the value’s type:

Type What is measured
String Character count, via mb_strlen — non-Latin scripts count correctly
Array Element count
Number Digits in its written form: 100 → 3, -5 → 2

There are only two forms — exact and range. #[Size(2, 255)] is the same as #[Size(min: 2, max: 255)] if you prefer named arguments.

Strings and formats

Constraint Checks Default message
#[Email] A valid email address must be a valid email address
#[Url] A valid URL must be a valid URL
#[Regex('/^[a-z-]+$/')] A match against the pattern must match pattern …
#[In(['draft', 'sent'])] A value from the list must be one of [draft, sent]
#[Uuid] A valid UUID of any version must be a valid UUID
#[Uuid(4)] A UUID of the given version must be a valid UUID v4

#[In] takes a second argument strict (true by default) — type-aware comparison. Pass strict: false if the string "1" should count as equal to the number 1.

Network and phone

Constraint Checks Default message
#[Ip] An IP address of any version must be a valid IP address
#[Ipv4] IPv4 only must be a valid IPv4 address
#[Ipv6] IPv6 only must be a valid IPv6 address
#[Msisdn] An E.164 number without +: 7–15 digits must be a valid MSISDN (7–15 digits, no + prefix)
#[Phone] A number with +, spaces, dashes, brackets must be a valid phone number

#[Msisdn] is the stricter one: it is for a number heading into a gateway, so it allows digits only. #[Phone] is for what a human typed.

Date and time

Constraint Checks Default message
#[Date] A date in Y-m-d must be a valid date (Y-m-d)
#[Date('d.m.Y')] A date in your own format must be a valid date (d.m.Y)
#[Time] A time as H:i or H:i:s must be a valid time (H:i or H:i:s)
#[Time('H:i')] Strictly the given format
#[Datetime] A date and time in ISO 8601 must be a valid datetime
#[Datetime('Y-m-d H:i:s')] In your own format

These constraints check a string. If the field is declared as DateTimeImmutable, parsing happens earlier, during binding, and an invalid date produces 400 before validation is reached.

Nested objects and collections

A nested object

Declaring a field with another object’s type is enough — checking descends into it by itself, no #[Valid] on the field required:

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,     // checked along with its parent
  ) {}
}
json
{"errors": {"filter.minPrice": ["must be at least 0"]}}

A collection of objects

An array has no element type, so it is named with the #[ListOf] attribute. Each element is parsed and checked like an ordinary object:

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"]}}

The index in the key shows exactly which element failed — uploading a batch of a hundred positions, that is the only thing that lets you find the culprit.

Bulk upload

When a batch of objects arrives as the root of the body, a variadic parameter takes the place of a collection. Error keys then start with the index:

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

Custom messages

Every constraint takes a message argument that replaces the default text:

php
#[Size(2, 100, message: 'The name must be between 2 and 100 characters')]
public readonly string $name,

#[Min(0, message: 'Quantity cannot be negative')]
public readonly int $qty,

Translation

If the message is wrapped entirely in braces it is treated as a translation key and resolved through localization:

php
#[Size(2, 100, message: '{order.name_length}')]
public readonly string $name,
resources/lang/en.php
return [
  'order' => [
      'name_length' => 'Field ":field": from :min to :max characters',
  ],
];

The substitutions include :field — the field name — and any public property of the constraint itself: :min, :max, :value, :format, :pattern, :values. Which ones are available is visible from the constructor arguments.

A key that is not found comes back as it is, with no exception — a typo in the key shows up as a string in the response rather than as a crash.

A check of your own — #[Assert]

When no built-in constraint fits, plug in your own function. It receives the value and the field name, and returns null on success or a string with the error:

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,
  ) {}
}

The attribute is repeatable — one field can carry several checks and all of them run:

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

When `#[Required]` is genuinely needed

A field declared without null and without a default is required by itself: if it is absent from the request, structure parsing answers is required with no constraints involved. #[Required] is for nullable fields, or fields with a default, that you nonetheless want to demand from the client.

Your own constraint — the Constraint interface

#[Assert] is good for a one-off check. When a rule repeats across several DTOs, or has parameters of its own, make it your own attribute — it will be no different from the built-in ones.

You need a class implementing Constraint: one method, null on success, a string with the error on failure.

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;                       // an empty field is #[Required]'s business
      }

      return is_int($value) && $value % $this->step === 0
          ? null
          : $this->message ?? "must be divisible by {$this->step}";
  }
}

From then on it is used like any built-in one, alongside them and with its own message:

php
class CreateOrderDto
{
  public function __construct(
      #[Positive]
      #[DivisibleBy(100)]
      public readonly int $amount,

      #[DivisibleBy(50, message: 'Weight is specified in steps of 50 grams')]
      public readonly int $weight,
  ) {}
}

Three rules

The attribute targets a parameter. #[Attribute(Attribute::TARGET_PARAMETER)] — a DTO is described by constructor parameters, and the checks live there too. Add Attribute::IS_REPEATABLE if hanging the rule twice makes sense.

Pass null through. An absent value is the business of #[Required] and of the property type, not yours. Every built-in constraint works that way: #[Size(2, 100)] on a nullable field stays silent until a value arrives. Without that check your rule starts demanding a field where it is optional.

The message is returned as a string. It lands in the 422 response under the field name. The message argument for overriding it is a convention every built-in constraint follows, and yours should too. A string in braces is treated by the framework as a translation key — see Localization.

Which to choose

#[Assert] Your own Constraint
What you write A static function An attribute class
Rule parameters None — only the callable name Any, through the constructor
Reuse By a method reference Like a built-in attribute
When to use A one-off check in a single DTO A rule that repeats

The difference is only in packaging: the contract is the same — (mixed $value, string $field): ?string — and both forms run in the same validation pass.

Next