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.
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:
<?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 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.
#[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.
{
"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:
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
) {}
}{"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:
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"]}}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:
#[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:
#[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:
#[Size(2, 100, message: '{order.name_length}')]
public readonly string $name,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:
<?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,
) {}
}The attribute is repeatable — one field can carry several checks and all of them run:
#[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.
<?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:
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
- Requests — where the object comes from, and how
400differs from422 - Error handling — what the client receives on a failure
- Localization — translating messages
- Controllers — where
#[Valid]is placed