Web basics

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.

Request object HttpRequestAttributes Http\Request\AnnotationCasting to the argument type

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:

php
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:

  1. reads the declared type and the source attribute;
  2. finds the value in the right part of the request;
  3. checks whether the parameter is required when there is no value;
  4. casts it to the declared PHP type;
  5. passes it in as the argument.
php
#[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:

php
#[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
php
#[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]

php
#[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
php
#[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]:

main/OrderFilter.php
class OrderFilter
{
  public function __construct(
      public readonly int     $page   = 1,
      public readonly int     $limit  = 20,
      public readonly ?string $search = null,
  ) {}
}
php
#[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.

php
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
php
#[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:

php
#[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.

php
#[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:

php
#[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:

php
#[RequestJson(field: 'user.email')] string $email,      // {"user":{"email":"..."}}
#[RequestBody(field: 'filter.minPrice')] int $min,      // cast to int

The value is required by default, and constraints on such an argument fire by themselves — no #[Valid] needed:

php
#[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
php
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:

php
#[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:

text
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:

php
#[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:

php
#[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:

php
#[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:

php
#[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
php
#[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:

php
#[RequestFile('avatar')] ?array $avatar = null,   // no file → null

Request 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:

text
?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 8601

Exact 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:

php
int  $page          // required: no value → 400
int  $page = 1      // no value → 1
?int $page          // no value → null
?int $page = null   // the same, but more explicit

The 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.

text
 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 → null

Item 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:

text
Cannot resolve parameter '$foo' in PostController::create()
— add an annotation or a default value

That 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:

php
// ✗ 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