Requests and parameter binding
Winter fills your method arguments with request data. You declare what you
need — a type and a source attribute — and the framework reads the request, casts
the value and passes it in. No $_GET, no $_POST and no json_decode in
controller code.
What parameter binding is and why
Parameter binding is the automatic conversion of a raw HTTP request into ready, typed method arguments.
The problem. Data from HTTP arrives as strings and sits in different places:
some in the URL path, some in the query string, some in the body (JSON, a form or
XML), some in headers. Parsing it by hand, checking presence, casting "42" to
int — that is routine repeated in every method and easy to get wrong. And the
mistakes are quiet: a forgotten null check surfaces not here but three layers
deeper.
The solution. Describe the parameter with a type and a source attribute — the
framework does the rest: finds the value in the right part of the request, casts it
to the declared type, and on a mismatch answers 400 before your code is entered.
We start, though, with what sits underneath binding — the request object itself.
The request object
HttpRequest is the entire incoming request as one object. To get it, declare an
argument of that type; no attribute is needed:
use Flytachi\Winter\Kernel\Http\Contracts\HttpRequest;
#[PostMapping('webhook')]
public function webhook(HttpRequest $request): ResponseEntity
{
$signature = $request->getHeader('X-Signature');
$payload = $request->getRawBody();
// ...
}It is an interface, not a class: behind it stands either SwooleRequest or
FpmRequest, depending on how the application was started. Your code sees no
difference — that is the point.
Request data
| Method | What it returns |
|---|---|
getMethod() |
The HTTP verb, upper case: GET, POST, … |
getUri() |
The path together with the query string: /users/42?page=1 |
getQueryParams() |
The parsed query string as an array — the $_GET equivalent |
getParsedBody() |
The parsed form body — the $_POST equivalent |
getRawBody() |
The body as it is, in bytes |
getUploadedFiles() |
Uploaded files — the $_FILES equivalent |
Headers
| Method | What it returns |
|---|---|
getHeader(string $name) |
One header or null. The name is case-insensitive |
getHeaders() |
Every header; keys lower-cased |
Who is calling, and from where
This group answers questions whose answer behind a reverse proxy does not match what the socket sees. All of them take proxy headers into account:
| Method | What it returns |
|---|---|
getClientIp() |
The client IP, honouring X-Forwarded-For and Forwarded |
getScheme() |
http or https — honouring X-Forwarded-Proto |
getHost() |
The host name without a port, as the client typed it |
getPort() |
The port as the client saw it, honouring X-Forwarded-Port |
getBaseUrl() |
A ready scheme://host[:port], standard ports omitted |
getClientTimezone() |
The timezone from the Timezone or X-Timezone header |
getServerParam(string $key) |
A request environment variable: remote_addr, request_time… |
Proxy headers are trusted unconditionally
getScheme(), getHost(), getPort() and getClientIp() take their values from
Forwarded and X-Forwarded-* without checking who set them. That is correct
behind a reverse proxy that overwrites those headers, and dangerous without one:
anybody can send them. If the application faces the internet directly, strip them at
the edge.
The response object is declared the same way, by the HttpResponse type. It is
rarely needed: normally the response is assembled by returning a value from the
method — see Controllers.
How binding works
You rarely need HttpRequest directly — almost everything people take out of it,
the framework can lay out into arguments by itself.
Once a route matches, ParameterResolver runs before the method is called. It walks
the method’s parameters and, for each one:
- reads the declared type and the source attribute;
- finds the value in the right part of the request;
- checks whether the parameter is required when there is no value;
- casts it to the declared PHP type;
- passes it in as the argument.
#[GetMapping('orders/{id:\d+}')]
public function show(
#[PathVariable] int $id, // from the path
#[RequestParam] int $page = 1, // from the query ?page=
#[RequestHeader] string $authorization, // from a header
): ResponseEntity {
// everything already found, checked and cast
}Compare that with the same code over a raw request: three lookups, three presence checks, two casts and not one intelligible error message.
Where data comes from
Every place in a request has its own attribute. They all live in
Flytachi\Winter\Kernel\Http\Request\Annotation:
| Attribute | Where it reads from | What it accepts |
|---|---|---|
#[PathVariable] |
A path segment — /users/{id} |
A scalar |
#[RequestParam] |
One query parameter — ?key=val |
A scalar or an array |
#[RequestQuery] |
The whole query string | An object or array only |
#[RequestHeader] |
A request header | A scalar |
#[RequestBody] |
The body; the format follows Content-Type |
A string, array, object or DTO |
#[RequestJson] |
The body — forced as JSON | An array, stdClass or DTO |
#[RequestForm] |
The body — forced as a form | An array, stdClass or DTO |
#[RequestXml] |
The body — forced as XML | An array, stdClass or DTO |
#[RequestFile] |
An uploaded file | The file’s data array, or its contents |
One section per source follows.
The path — #[PathVariable]
Takes a dynamic URL segment. The argument name must match the segment name in the route:
#[GetMapping('users/{id}/posts/{slug}')]
public function post(
#[PathVariable] int $id, // /users/42/... → 42
#[PathVariable] string $slug, // .../hello → "hello"
): ResponseEntity { /* ... */ }The link is by name, not by position — the argument order is irrelevant.
Attribute arguments
| Argument | Default | What it does |
|---|---|---|
name |
the argument name | The segment name in the route, when it differs |
#[GetMapping('posts/{id}')]
public function show(#[PathVariable('id')] int $postId): ResponseEntity { /* ... */ }The segment is always required: if the route matched, the value exists. Path patterns and regex constraints themselves are on the Routing page.
The query string
There are two attributes for the query, and choosing between them is choosing between “several separate values” and “one filter object”.
One value — #[RequestParam]
#[GetMapping('orders')]
public function list(
#[RequestParam] int $page = 1, // ?page=2
#[RequestParam] ?string $search = null, // ?search=phone
#[RequestParam] bool $active = false, // ?active=true
#[RequestParam] array $ids = [], // ?ids[]=1&ids[]=2
): ResponseEntity { /* ... */ }The name is looked up in three spellings. An argument $pageSize catches
?pageSize=, ?page_size= and ?page-size= — the frontend does not have to match
PHP’s naming style.
An array arrives as an array of strings
?ids[]=1&ids[]=2 gives ["1", "2"], not [1, 2]: elements are not cast because
there is nowhere to declare their type — array in PHP is untyped. Cast them
yourself, or describe the field in a DTO.
Attribute arguments
| Argument | Default | What it does |
|---|---|---|
name |
the argument name | The exact parameter name in the query |
#[RequestParam('per_page')] int $perPage = 20, // only ?per_page=An explicit name switches off the three-spelling lookup — only an exact match
counts. That is a trap: with #[RequestParam('page_size')] an incoming ?pageSize=25
does not match and silently falls back to the default.
Many values — #[RequestQuery]
When there are many filter parameters, one object is nicer than a dozen arguments.
Fields are filled from the query by name and cast to the constructor’s types, by the
same rules as #[RequestParam]:
class OrderFilter
{
public function __construct(
public readonly int $page = 1,
public readonly int $limit = 20,
public readonly ?string $search = null,
) {}
}#[GetMapping('orders')]
public function list(#[RequestQuery] OrderFilter $filter): ResponseEntity
{
// ?page=2&limit=50&search=nokia → $filter->page === 2 (int, not a string)
}The attribute is always optional: an empty query string produces an object with its defaults rather than an error. So it makes sense to give every DTO field a default.
Besides your own class it accepts array (then the whole query arrives as it is)
and stdClass. To have the fields checked as well, add #[Valid] — see
Validation.
A scalar here crashes the request
#[RequestQuery] accepts only an object or array. On int, string and other
scalars it throws a LogicException, which is a 500, not a 400:
Request query string parameter '$page' has unsupported type 'int'
For a single value you want #[RequestParam]. The mistake passes review easily,
because it looks plausible.
Headers — #[RequestHeader]
The header name is derived from the argument name: camelCase and snake_case
become kebab-case, and the lookup is case-insensitive.
public function handle(
#[RequestHeader] string $authorization, // Authorization
#[RequestHeader] ?string $xRequestedWith, // X-Requested-With
#[RequestHeader] ?string $accept_language, // Accept-Language
): ResponseEntity { /* ... */ }Attribute arguments
| Argument | Default | What it does |
|---|---|---|
name |
the argument name in kebab-case | The exact header name |
#[RequestHeader('X-Trace-Id')] string $trace,An explicit name helps when a header does not translate into a variable name
nicely — and case still does not matter, so 'x-trace-id' and 'X-Trace-Id' are
equivalent.
A missing header is a 400
A header, like every other source, is required by default. A client that forgot
Authorization receives a 400, not a null inside your method. To make a header
optional, declare the type ?string or give it a default.
For several headers at once, or ones needed conditionally, it is simpler to take
HttpRequest and call getHeader() — binding does not have to cover everything.
The request body
The body is the busiest source: it carries JSON, a form or XML, and has to land sometimes in an object, sometimes in an array, sometimes whole in a string. Hence four attributes: one that detects the format and three that fix it.
#[RequestBody] — format by Content-Type
The main option. The format is decided by the header, and what to parse into by the argument type:
#[PostMapping('orders')]
public function create(#[RequestBody] CreateOrderDto $dto): ResponseEntity
{
// the body is parsed and spread across the DTO's fields
}| Argument type | What arrives |
|---|---|
| A DTO class | An object with fields filled from the body and cast to the constructor’s types |
array |
The parsed body as an array |
stdClass |
The parsed body as an object |
string |
The raw body in bytes; Content-Type is ignored |
Dto ...$items |
A list of objects from a JSON array |
The format follows Content-Type:
| Header | How it is parsed |
|---|---|
application/xml, text/xml |
As XML |
application/x-www-form-urlencoded, multipart/form-data |
As a form |
| Everything else | As JSON |
So a form is parsed on equal terms with JSON — a dedicated attribute is needed only when the format must be fixed rather than trusted from the client’s header.
The raw body
An argument of type string gives the body as it is, parsing nothing. That is what
signed webhooks need: the signature is computed over the original bytes, and any
parse-and-rebuild breaks it.
#[PostMapping('webhook')]
public function webhook(
#[RequestBody] string $raw,
#[RequestHeader] string $xSignature,
): ResponseEntity {
if (!hash_equals($xSignature, hash_hmac('sha256', $raw, env('WEBHOOK_SECRET')))) {
throw new RequestException('Bad signature');
}
// ...
}A single field — the field argument
When you need one value out of the body, there is no need to declare a class for it.
The field argument pulls the value by key and casts it to the argument’s type:
#[PatchMapping('{id:\d+}/name')]
public function rename(
#[PathVariable] int $id,
#[RequestBody(field: 'name')] string $name,
): ResponseEntity { /* {"name": "..."} → $name */ }Dot notation is supported for nested structures:
#[RequestJson(field: 'user.email')] string $email, // {"user":{"email":"..."}}
#[RequestBody(field: 'filter.minPrice')] int $min, // cast to intThe value is required by default, and constraints on such an argument fire by
themselves — no #[Valid] needed:
#[RequestBody(field: 'name'), Size(5, 40)] string $name,The field argument exists on all four body attributes.
Forcing the format
When a client sends the wrong Content-Type, or the format has to be pinned down,
take the attribute with an explicit format instead of auto-detection:
| Attribute | How it parses the body |
|---|---|
#[RequestJson] |
Always as JSON |
#[RequestForm] |
Always as a form, whatever the header says |
#[RequestXml] |
Always as XML |
public function a(#[RequestJson] CreateOrderDto $dto): ResponseEntity {}
public function b(#[RequestForm] array $form): ResponseEntity {}
public function c(#[RequestXml] \stdClass $node): ResponseEntity {}Their rules for argument types and field are the same as #[RequestBody]’s.
An array of objects
A variadic parameter unfolds a JSON array into a set of DTOs — for bulk operations:
#[PostMapping('orders/bulk')]
public function bulk(#[RequestBody, Valid] OrderDto ...$orders): ResponseEntity
{
// [{"..."}, {"..."}] → $orders[0], $orders[1], ...
}The body must be a JSON array; an object or anything else gives a 400 reading
Expected JSON array for variadic body. Errors are numbered by element index, so it
is clear which object in the batch failed.
Parsing errors
DTO fields are checked for presence and type, and all errors are collected at once rather than stopping at the first:
POST /orders {}
400 {"name": ["is required"], "qty": ["is required"]}That is structural checking: the field exists, the field has the right type.
Content rules — length, range, format — are switched on by #[Valid] on the
parameter; see Validation.
Files
#[RequestFile] binds a file uploaded through multipart/form-data. Besides
binding, the attribute can check the size and the type — that is, reject an
unsuitable upload before your method is entered.
One file
An argument of type array receives the file’s data — the same keys as in
$_FILES:
#[PostMapping('avatar')]
public function upload(#[RequestFile('avatar')] array $file): ResponseEntity
{
// ['name' => 'photo.jpg', 'type' => 'image/jpeg',
// 'tmp_name' => '/tmp/php7Xy', 'size' => 12345, 'error' => 0]
move_uploaded_file($file['tmp_name'], $target);
}If you want the contents rather than the description, declare the argument string
and the file is read for you:
#[PostMapping('import')]
public function import(#[RequestFile('report')] string $csv): ResponseEntity
{
// $csv is the file's bytes; no need to read the temporary file yourself
}Several files
A field like <input name="images[]" multiple> is handled with the multiple flag.
A list arrives even when a single file was sent:
#[PostMapping('gallery')]
public function gallery(#[RequestFile('images', multiple: true)] array $images): ResponseEntity
{
foreach ($images as $image) { /* each one is the same data array */ }
}With no field name, the whole map of uploaded files arrives — form field names as keys, descriptions as values:
#[RequestFile] array $files, // ['avatar' => [...], 'images' => [[...], [...]]]Checking size and type
| Argument | Default | What it does |
|---|---|---|
name |
— | The form field name. Without it, the whole file map arrives |
multiple |
false |
Expect a list of files rather than one |
maxSize |
none | Size limit; over it — 400 |
accept |
none | Allowed types; a mismatch — 400 |
#[RequestFile('avatar', maxSize: '5MB', accept: ['image/jpeg', 'image/png'])] array $avatar,maxSize understands B, KB, MB, GB and fractional values ('1.5MB'); the
units are binary, so 1KB is 1024 bytes. A bare number counts as bytes. With
multiple: true the limit applies to each file separately.
accept takes three forms:
| Form | Example | How it is checked |
|---|---|---|
| An exact MIME type | 'application/pdf' |
By the file’s contents |
| A MIME mask | 'image/*' |
By the file’s contents |
| An extension | '.pdf' |
By the file name the client sent |
An extension is not a check
The first two forms determine the type from the file’s signature, via finfo,
not from the Content-Type the browser sent: a renamed or substituted file is
rejected.
The extension form compares only the name that came from the client. A shell.php
renamed to shell.pdf passes it. Use it only where the type does not affect
security — everywhere else list MIME types.
What to expect on failure
Every check answers 400 and does not let the request into the controller:
| What happened | Message |
|---|---|
| The field was not sent | Uploaded file 'avatar' is missing |
| A transfer failure | Uploaded file 'avatar' transfer error (code 1) |
| Too large | Uploaded file 'avatar' exceeds maximum size of 5MB |
| Wrong type | Uploaded file 'avatar' type 'application/pdf' is not allowed (accepted: image/jpeg, image/png) |
A file is made optional the usual way — with a default value or a ?array type:
#[RequestFile('avatar')] ?array $avatar = null, // no file → nullRequest size is limited separately
An upload larger than the server’s maxRequestSize (8 MB by default) never reaches
these checks — the server answers 413 earlier. That is raised in the web-layer
settings; see Web-layer configuration.
Type casting
Everything from HTTP arrives as a string. The resolver casts the value to the type
declared on the argument, and when the cast is impossible it answers 400 before
the method is entered.
| Argument type | What it accepts | Example |
|---|---|---|
int |
A whole number as a string | "42" → 42 |
float |
A number with a point | "3.14" → 3.14 |
bool |
true/false, 1/0, yes/no, on/off |
"yes" → true |
string |
Anything | taken as it is |
array |
An array only (?ids[]=) |
a scalar is rejected |
| A backed enum | A value from the enum | "draft" → Status::Draft |
DateTimeImmutable, DateTime |
A date in ISO 8601 | "2024-01-31T12:00:00" |
BcMath\Number, Decimal\Decimal |
A numeric string | an exact number, losslessly |
Error messages name both the parameter and the expectation:
?page=abc → 400 Query parameter 'page' must be an integer, got 'abc'
?status=xx → 400 Query parameter 'status' must be one of [draft, published], got 'xx'
?from=31-01 → 400 Query parameter 'from' has invalid date '31-01' — expected ISO 8601Exact numbers
BcMath\Number and Decimal\Decimal receive the value as a string, bypassing
float, so "1.1" stays 1.1 rather than becoming 1.1000000000000000888. For
money that is the only safe option. Both types need their PHP extension — without it
the value arrives as it is, uncast.
An array where a scalar was expected
?id[]=1 with an int $id argument gives 400 must be int, got array — a client
cannot slip an array in where one value was expected. The exceptions are mixed and
iterable, which do accept an array.
Required and optional
A parameter from an HTTP source is required by default. There are two ways to make it optional:
int $page // required: no value → 400
int $page = 1 // no value → 1
?int $page // no value → null
?int $page = null // the same, but more explicitThe resolution order when a value is absent: a default exists — return it; the type
allows null — return null; otherwise — 400.
An empty string is a value that is present
?page (no parameter) and ?page= (a parameter that is empty) are different cases.
The first takes the default, the second goes into casting, and there the outcome
depends on the type:
| Type | ?x= gives |
|---|---|
int, float |
400 — an empty string is not a number |
DateTimeImmutable, an enum |
400 |
bool |
false — an empty string counts as false |
string |
An empty string; that is a valid value |
So ?active= is not an error but a switched-off flag.
Resolution rules
The resolver checks sources in a fixed order and takes the first match. You need to know the order in one case: when a parameter ended up with two attributes, the one higher in the list wins.
1. #[PathVariable] a path segment
2. #[RequestParam] one query parameter
3. #[RequestBody] the body, format by Content-Type
4. #[RequestFile] an uploaded file
5. #[RequestJson] the body as JSON
6. #[RequestForm] the body as a form
7. #[RequestXml] the body as XML
8. #[RequestQuery] the whole query string
9. #[RequestHeader] a header
10. type HttpRequest the request object
11. type HttpResponse the response object
12. name match a path segment with no attribute
13. a default value
14. the type allows null → nullItem 12 explains why #[PathVariable] may be omitted: when the argument name
matches the segment name, the value is found without the attribute. The code reads
more clearly with it, so the examples keep it.
If no rule matched, the request fails with a RuntimeException:
Cannot resolve parameter '$foo' in PostController::create()
— add an annotation or a default valueThat is an error in the method signature, not in the client’s request, so the
response is 500. And it appears when the endpoint is called, not at startup:
the resolver runs per request. Exercise new handlers at least once.
One type per parameter
Union and intersection types on HTTP parameters are not supported — the resolver rejects them before any casting:
// ✗ LogicException: Union/intersection type on '$value' is not supported
public function a(#[RequestParam] int|string $value): void {}
// ✓ one type
public function b(#[RequestParam] string $value): void {}The reason is simple: a union has no single casting rule — it is unclear what the
string "42" should become.
Next
- Validation —
#[Valid]and content checks on fields - Routing — path patterns for
#[PathVariable] - Responses — formats and status codes
- Error handling — what a
400turns into - Controllers — where these signatures live