# Routing & Validation Kinetis's routing and validation are both attribute-driven — routes, parameter binding, and constraint validation are declared directly on your controller classes and DTOs, with no separate YAML/XML/array configuration file to keep in sync. ## Route attributes ```{code-block} php use Kinetis\Http\Attributes\{Get, Post, Put, Patch, Delete}; final readonly class UserController { #[Get('/users')] public function index(): array { /* ... */ } #[Post('/users', status: 201)] public function store(): array { /* ... */ } #[Put('/users/{id}')] public function replace(int $id): array { /* ... */ } #[Patch('/users/{id}/status')] public function updateStatus(int $id): array { /* ... */ } #[Delete('/users/{id}')] public function destroy(int $id): array { /* ... */ } } ``` All five implement a shared `RouteAttribute` interface (`httpMethod()`, `path()`, `status()`) — so adding a sixth verb, if you ever needed one, is a matter of implementing that interface, not touching `Router` itself. `Router::register()` reflects every **public** method on the class once, at boot: methods without a route attribute are silently skipped, so a controller can freely mix routed actions with plain helper methods. Each `{placeholder}` in a path template is compiled to a named regex capture group exactly once, at registration — not on every request. ```{code-block} php use Kinetis\Http\Routing\Router; $router = new Router(); $router->register(UserController::class); $match = $router->match('GET', '/users/42'); // $match->route->controllerMethod === 'show' // $match->pathParams === ['id' => '42'] ``` In a real application, `register()` is called for you: any class anywhere under one of your own PSR-4 roots is discovered and registered automatically, with no required directory or namespace convention — see {doc}`getting-started` and {doc}`cli` (including how to restrict the scan for a large application). ## Parameter binding A controller method's parameters are resolved from five possible sources, checked in this order: ### `#[Body]` A parameter attributed `#[Body]` is bound to the decoded JSON request body. Its declared type must be a class — that class is the DTO `Hydrator` builds and validates (see [Validation](#validation-constraints) below) *before the controller method ever runs*. ```{code-block} php #[Post('/users')] public function store(#[Body] CreateUserRequest $data): UserResponse ``` ### `#[Query]` A parameter attributed `#[Query]` is bound to a query-string value of the same name, cast to the parameter's declared scalar type. A missing value falls back to the parameter's default, or `null` if it has none — never an error. ```{code-block} php #[Get('/users')] public function index(#[Query] int $page = 1, #[Query] int $limit = 20) ``` ### Path parameters A parameter with no attribute at all is matched by name against a `{placeholder}` in the route's path template, if one exists with the same name, and cast to the parameter's scalar type. ```{code-block} php #[Get('/users/{id}')] public function show(int $id) ``` ### `ServerRequestInterface` A parameter typed `ServerRequestInterface` receives the raw PSR-7 request directly — no attribute needed, checked ahead of the others. Bypasses `#[Body]`'s decoding assumptions entirely, for anything that needs the request itself: a raw body stream, headers, a different content type. ```{code-block} php use Psr\Http\Message\ServerRequestInterface; #[Post('/webhooks')] public function receive(ServerRequestInterface $request): array ``` ### `UploadedFileInterface` A parameter typed `UploadedFileInterface` — no attribute needed, checked alongside `ServerRequestInterface` — is resolved directly from the request's uploaded-files bag by parameter name. See [Multipart/form-data & file uploads](#multipart-form-data-file-uploads) below. ```{code-block} php use Psr\Http\Message\UploadedFileInterface; #[Post('/files')] public function receiveFile(UploadedFileInterface $file): array ``` A parameter matching none of the above falls back to its default value, if it has one; if it doesn't, the request fails with a clear "unresolvable parameter" error rather than passing `null` silently. (multipart-form-data-file-uploads)= ## Multipart/form-data & file uploads `#[Body]` isn't limited to JSON. `Dispatcher` picks how to read the body from the request's `Content-Type`: | Content-Type | Read from | |---|---| | `application/json` (or anything else) | `json_decode()` on the raw body | | `multipart/form-data` | `getParsedBody()` | | `application/x-www-form-urlencoded` | `getParsedBody()` | A `#[Body]` DTO can mix ordinary fields with an `UploadedFileInterface`-typed constructor parameter — no special handling needed in the DTO itself: ```{code-block} php use Psr\Http\Message\UploadedFileInterface; final readonly class AvatarUploadRequest { public function __construct( public string $name, public UploadedFileInterface $avatar, ) {} } ``` ```{code-block} php #[Post('/avatars')] public function upload(#[Body] AvatarUploadRequest $data): array { return [ 'filename' => $data->avatar->getClientFilename(), 'contents' => (string) $data->avatar->getStream(), ]; } ``` Validation constraints (`#[MinLength]`, `#[Regex]`, ...) work identically on a multipart-bound DTO's ordinary fields as on a JSON one — `Hydrator` never knows or cares which content type produced the data it's validating. An `UploadedFileInterface`-typed parameter doesn't have to sit inside a `#[Body]` DTO — a top-level controller parameter of that type, with no attribute, is resolved directly from the request's uploaded-files bag by parameter name: ```{code-block} php use Psr\Http\Message\UploadedFileInterface; #[Post('/files')] public function receiveFile(UploadedFileInterface $file): array { return ['filename' => $file->getClientFilename()]; } ``` ```{note} This works the same way regardless of which `RuntimeAdapterInterface` is driving the request — `FrankenPhpAdapter`/`FpmAdapter` populate the uploaded-files bag via PHP 8.4's `request_parse_body()` for `PUT`/`PATCH` (PHP's SAPI only does this automatically for `POST`), and `kinetis/bref-adapter`'s `BrefLambdaAdapter` parses it from the Lambda event body directly. See {doc}`runtime-adapters` for what differs underneath each one. ``` ## Returning a status other than the route's default `#[Get('/users/{id}')]`'s `status` argument (default `200`) is only the status used when the controller returns plain data — an array or a DTO. Return a PSR-7 `ResponseInterface` directly instead, and `Dispatcher` passes it through untouched, with whatever status/headers/body you gave it: ```{code-block} php use Kinetis\Http\Attributes\Get; use Kinetis\Http\Attributes\Response; use Kinetis\Http\Responses\ErrorResponse; use Psr\Http\Message\ResponseInterface; final readonly class UserController { public function __construct( private UserRepository $users, ) {} #[Get('/users/{id}')] #[Response(404, description: 'User not found.')] public function show(int $id): ResponseInterface|array { $user = $this->users->find($id); if ($user === null) { return ErrorResponse::create(404, "User {$id} not found."); } return $user; } } ``` Two different things are happening here, and they don't depend on each other: - The `return ErrorResponse::create(...)` **is what actually produces** the 404 at request time — `Dispatcher` sees a `ResponseInterface` and passes it through untouched instead of wrapping it in the route's default status. - The `#[Response(404, description: ...)]` attribute **only documents** that possible outcome for `/openapi.json` — see [Zero-config OpenAPI & Swagger UI](#zero-config-openapi--swagger-ui) below. `Dispatcher` never reads it; only `OpenApiGenerator` does. Nothing enforces that the two agree — you could return a 404 without declaring it, or declare a status the method never actually returns. ## Returning HTML, files, and redirects Any route can return something other than JSON, using the same `ResponseInterface` passthrough — Kinetis ships a few response builders for the common cases: ```{code-block} php use Kinetis\Http\Attributes\Get; use Kinetis\Http\Responses\FileResponse; use Kinetis\Http\Responses\HtmlResponse; use Kinetis\Http\Responses\RedirectResponse; use Psr\Http\Message\ResponseInterface; final readonly class PagesController { #[Get('/welcome')] public function welcome(): ResponseInterface { return HtmlResponse::create('

Welcome

'); } #[Get('/avatars/{id}')] public function avatar(int $id): ResponseInterface { return FileResponse::fromPath("/storage/avatars/{$id}.png"); } #[Get('/old-url')] public function oldUrl(): ResponseInterface { return RedirectResponse::to('/new-url', 301); } } ``` - `HtmlResponse::create(string $html, int $status = 200)` sets `Content-Type: text/html`. - `FileResponse::fromPath(string $path, int $status = 200, ?string $contentType = null, ?string $downloadFilename = null)` reads a file from disk and detects its content type automatically when `$contentType` is omitted. `FileResponse::fromContents(string $contents, string $contentType, int $status = 200, ?string $downloadFilename = null)` does the same for data you already have in memory — a generated image or PDF, for instance. Either one adds a `Content-Disposition: attachment` header when `$downloadFilename` is given. - `RedirectResponse::to(string $url, int $status = 302)` sets a `Location` header. - `ErrorResponse::create(int $status, string $message)` builds `{"error": "..."}` at the given status, the same shape Kinetis's own 404/405/500 responses already use. ## Validation constraints DTOs referenced by a `#[Body]` parameter declare their validation rules as attributes directly on constructor-promoted properties: ```{code-block} php use Kinetis\Validation\Constraints\{Email, MinLength, GreaterThan, Regex}; final readonly class CreateProductRequest { public function __construct( #[Regex('/^[A-Z]{3}\d{3}$/')] public string $sku, #[GreaterThan(0)] public float $price, ) {} } ``` | Attribute | Checks | Constructor | |---|---|---| | `#[Email]` | `filter_var($value, FILTER_VALIDATE_EMAIL)` | *(no arguments)* | | `#[NotBlank]` | not empty or all-whitespace after `trim()` | *(no arguments)* | | `#[MinLength(n)]` | `mb_strlen($value) >= n` | `int $length` | | `#[MaxLength(n)]` | `mb_strlen($value) <= n` | `int $length` | | `#[GreaterThan(n)]` | `$value > n` | `int\|float $threshold` | | `#[LessThan(n)]` | `$value < n` | `int\|float $threshold` | | `#[Regex($pattern)]` | `preg_match($pattern, $value) === 1` | `string $pattern` | | `#[In($choices)]` | `in_array($value, $choices, true)` | `array $choices` | | `#[Url]` | `filter_var($value, FILTER_VALIDATE_URL)` | *(no arguments)* | | `#[Uuid]` | matches an RFC 4122 UUID | *(no arguments)* | `#[MinLength]`/`#[MaxLength]` and `#[GreaterThan]`/`#[LessThan]` compose on the same field for a length or numeric range — `Hydrator` runs every `Constraint`-implementing attribute on a parameter, not just the first one: ```{code-block} php #[GreaterThan(0)] #[LessThan(100)] public int $percentage, ``` `Hydrator::hydrate()` checks **every** constrained field before constructing the DTO — a request with three invalid fields gets all three errors back in one response, not just the first one it happened to encounter: ```{code-block} json { "errors": { "name": ["must be at least 3 characters."], "email": ["must be a valid email address."] } } ``` A failed validation short-circuits straight to a `422` — the controller method is never invoked at all. ### Asymmetric-visibility properties Because both `Hydrator` and `Dispatcher` reason about a DTO purely through constructor-parameter reflection, PHP 8.4's asymmetric visibility works with zero special-casing: ```{code-block} php use Kinetis\Validation\Constraints\MinLength; final class UpdateStatusRequest { public function __construct( #[MinLength(3)] public private(set) string $status, ) {} } ``` The property's visibility declaration is simply irrelevant to how it's bound and validated — the constructor parameter is what both classes actually inspect. ### Writing your own constraint A constraint is any class implementing the one-method `Constraint` interface: ```{code-block} php use Kinetis\Validation\Constraint; use Attribute; #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] final readonly class Uppercase implements Constraint { public function validate(mixed $value): ?string { if (!is_string($value) || $value !== strtoupper($value)) { return 'must be all uppercase.'; } return null; } } ``` Returning `null` means valid; any non-null string becomes that field's error message. Any attribute implementing `Constraint` on a parameter is picked up automatically — there is no fixed list of "known" constraints to register your own class into. ### Nested DTOs A constructor parameter typed as another class — not a builtin scalar — is hydrated as a nested DTO, recursively, the same way the top-level `#[Body]` DTO itself is: ```{code-block} php use Kinetis\Validation\Constraints\MinLength; final readonly class Address { public function __construct( #[MinLength(3)] public string $street, public string $city, ) {} } final readonly class CreateOrderRequest { public function __construct( #[MinLength(2)] public string $customerName, public Address $shippingAddress, ) {} } ``` ```{code-block} json { "customerName": "John Doe", "shippingAddress": { "street": "1 Infinite Loop", "city": "Cupertino" } } ``` A nested DTO's own validation runs the same way its parent's does — every field, top-level and nested, is checked before construction, and a nested field's error surfaces under a dotted key (`shippingAddress.street`) rather than only reporting the outer field name: ```{code-block} json { "errors": { "shippingAddress.street": ["must be at least 3 characters."] } } ``` This is a data-driven distinction, not a type-driven one: nesting only happens when the incoming value for that field is actually an array. A class-typed field holding anything else — most notably an `UploadedFileInterface` merged in for a [multipart](#multipart-form-data-file-uploads) field — passes through completely unchanged, exactly like it always has. ```{note} A self-referencing (or mutually referencing) DTO stops nesting the moment a class repeats in the chain, rather than recursing forever — not just a safety net, but a requirement of {doc}`caching`'s AOT compilation, which bakes a DTO's hydration plan into a cache file via `var_export()` and has no way to represent a genuinely circular array as re-parseable PHP. A self-referencing field simply receives its raw array unhydrated one level deep in that case. ``` ### Collections of nested DTOs A constructor parameter typed `array` and carrying `#[ListOf(SomeClass::class)]` is hydrated as a list of nested DTOs — each array-shaped element is hydrated the same way a single nested DTO field is: ```{code-block} php use Kinetis\Validation\Constraints\GreaterThan; use Kinetis\Validation\ListOf; final readonly class OrderItem { public function __construct( public string $product, #[GreaterThan(0)] public int $quantity, ) {} } final readonly class CreateOrderRequest { public function __construct( #[MinLength(2)] public string $customerName, #[ListOf(OrderItem::class)] public array $items, ) {} } ``` ```{code-block} json { "customerName": "John Doe", "items": [ { "product": "Widget", "quantity": 2 }, { "product": "Gadget", "quantity": 5 } ] } ``` Each element's own validation errors surface under a dotted `field.index.nestedField` key, alongside every other error in the same response: ```{code-block} json { "errors": { "items.1.quantity": ["must be greater than 0."] } } ``` A list element that isn't itself an array — most notably an already-constructed instance — passes through completely unchanged, the same tolerance a single nested DTO field gives a non-array value. ```{note} The same self-reference guard described above covers a `#[ListOf]` pointing back at its own class: nesting stops the moment the class repeats in the chain, and that list's elements receive their raw array unhydrated one level deep. ``` ## Zero-config OpenAPI & Swagger UI Every route `Router` has registered is reflected a second time — the same controller-method metadata `Dispatcher` already reads at request time — to build a complete OpenAPI 3.1 document, with no annotations beyond the attributes already shown on this page: - `GET /openapi.json` — the generated document. - `GET /docs` — a Swagger UI shell rendering it. `#[Body]` DTOs become `requestBody` schemas, with every constraint from the table above mapped onto the matching JSON Schema keyword (`format: email`, `minLength`/`maxLength`, `exclusiveMinimum`/`exclusiveMaximum`, `pattern`, `enum`, `format: uri`, `format: uuid`) — except `#[NotBlank]`, which has no distinct JSON Schema keyword of its own. `#[Query]` parameters and path parameters become `parameters` entries. A controller method's declared return type becomes the default response's schema too — `UserResponse` (or `?UserResponse`, or a union like `ResponseInterface|array` where `UserResponse` is one member) produces a `content` entry describing it; a bare `array`/`ResponseInterface`-only return, with no shape reflection can recover, leaves the response description-only, exactly as before this existed. A [`#[ListOf]` field](#collections-of-nested-dtos) becomes a `{"type": "array", "items": ...}` schema, with `items` describing the element class the same way any other DTO reference does. Every DTO schema — whether reached via a `requestBody`, a response, or a [`#[ListOf]`](#collections-of-nested-dtos) element, at any depth — is deduplicated into `components/schemas` and referenced by `$ref`, rather than inlined at each point of use: ```{code-block} json { "requestBody": { "content": { "application/json": { "schema": { "$ref": "#/components/schemas/CreateOrderRequest" } } } }, "components": { "schemas": { "CreateOrderRequest": { "type": "object", "properties": { "customerName": { "type": "string", "minLength": 2 }, "shippingAddress": { "$ref": "#/components/schemas/Address" } } }, "Address": { "type": "object", "properties": { "..." : "..." } } } } } ``` The component name is the DTO's short class name (`CreateOrderRequest`, not its full namespace) when nothing else has claimed it; two distinct classes that happen to share a short name fall back to the fully-qualified name instead of silently overwriting one schema with the other's. A route attribute's `status` only describes the *default* response — it has no way to know a method might also return a `ResponseInterface` directly with a different status. The repeatable `#[Response(status, description)]` attribute documents those additional statuses manually — see `UserController::show()` [above](#returning-a-status-other-than-the-routes-default) for a complete example. It's purely descriptive and has **no effect on runtime behavior**: `Dispatcher` never reads it, only `OpenApiGenerator` does. Each one adds one entry to that operation's `responses` alongside the route's default — nothing checks that the method actually produces the status it declares, the same trust already placed in the route attribute's own default. Both are served **ahead of** the routing pipeline — they read `Router`'s already-registered routes, not application state, so they need no `RequestScope` at all. Turn them off entirely with: ```{code-block} php new Kinetis\Http\Kernel($app, $router, exposeOpenApi: false); ``` ### Hiding a route from the document `#[Hidden]` excludes a route from `/openapi.json` and `/docs` — the route itself keeps working exactly as before, only its documentation is suppressed. Useful for a route that isn't really part of the API surface, like an HTML page served alongside a JSON API: ```{code-block} php use Kinetis\Http\Attributes\Get; use Kinetis\Http\Attributes\Hidden; use Kinetis\Http\Responses\HtmlResponse; use Psr\Http\Message\ResponseInterface; final readonly class DashboardController { #[Get('/')] #[Hidden] public function index(): ResponseInterface { return HtmlResponse::create('

Dashboard

'); } } ``` `#[Hidden]` on a controller class hides every route on it at once, for a controller that shouldn't appear in the document at all: ```{code-block} php #[Hidden] final readonly class InternalController { #[Get('/internal/status')] public function status(): array { /* ... */ } } ``` ## See also - {doc}`container` — how a controller's own constructor dependencies get resolved. - {doc}`middleware` — `#[Middleware]`, the same attribute-driven pattern applied to wrapping a route rather than binding its parameters. - {doc}`caching` — how route/binding/validation metadata gets precomputed ahead of time in production, and exactly what that does and doesn't change about the behavior described on this page. - {doc}`runtime-adapters` — how each runtime gets a request's multipart body into the uploaded-files bag `#[Body]`/`UploadedFileInterface` read from here.