# 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. Routes are discovered automatically: any class anywhere under one of your own PSR-4 roots is registered the moment a route attribute appears on one of its methods, with no required directory or namespace convention and nothing to register by hand — see {doc}`cli` (including how to restrict the scan for a large application, and how installed packages contribute discovered classes through their own `extra.kinetis` scan roots). 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 once, when the route is registered — not on every request. Matching is first-match-wins in registration order, so two routes may overlap — `/users/{id:\d+}` alongside `/users/{id}`, or `/users/self` alongside `/users/{id}` — and the earlier registration takes the requests both match. A second route claiming *exactly* the same requests (the same method and path shape — placeholder names don't count, so `/users/{id}` and `/users/{userId}` collide) is rejected at registration with a `DuplicateRouteException`, since it could never run at all. ### Constraining a placeholder's shape A plain `{id}` matches any run of characters up to the next `/`. Add an optional `:pattern` suffix — a raw regex fragment, no delimiters, no anchors — to constrain it further: ```{code-block} php #[Get('/orders/{id:\d+}')] public function show(int $id): array { /* ... */ } #[Get('/files/{hash:[0-9a-f]{40}}')] public function download(string $hash): array { /* ... */ } ``` A path segment that doesn't match the constraint never matches the route at all — `GET /orders/abc` against the first example above 404s the same way a completely unregistered path would, rather than reaching the controller with a value that would go on to fail a `#[Query]`/`#[Body]` constraint check instead. `{id:\d+}` and a `#[GreaterThan(0)]` on the same `int $id` parameter are complementary, not redundant: the route constraint decides whether this path matches *this route at all* (versus falling through to a 404, or to a different route registered for the same literal segment shape); a parameter constraint decides whether an already-matched value is *valid* (versus a 422). A fixed-length constraint like the SHA-1 example above needs its own `{n}`/`{n,m}` repetition quantifier, which is handled correctly even though it contains braces of its own — nothing about the placeholder syntax gets confused by a `{...}` inside the constraint. A pattern is regex text Kinetis inserts rather than rewrites, and the brace scanner that finds where the placeholder ends reads enough PCRE to know where a `}` is *not* that end. All of these parse and match correctly: | constraint | matches | |---|---| | `{value:\}}` | a literal `}` — an escaped brace | | `{value:[{]}` | a literal `{` — a brace as an ordinary character-class member | | `{value:[[:alpha:]{]}` | a letter or a literal `{` — a POSIX sub-form inside a class | | `{value:a\Q{\E}` | a literal `a{` — a `\Q...\E` quoted span | | `{value:\Q~\E}` | a literal `~` — the delimiter itself, inside a quoted span | | `{value:(?#})a}` | `a` — a `}` inside a `(?#...)` comment group | | `{value:[#~!%@|+\-=]+}` | any of those characters, delimiter included | The delimiter is `~`, and a literal occurrence of it in a pattern is escaped rather than dodged by picking a different one. Inside a `\Q...\E` span that escape needs a rewrite rather than a plain backslash — a backslash is literal text there, so `\~` would match two characters instead of one — so the span is closed and reopened around it. That happens automatically; the pattern you write is the pattern that runs. ```{warning} The scanner is a bounded reader of those constructs, not a full PCRE parser, and the supported constraint grammar is exactly what it can read faithfully. Two things fall outside it, and both are rejected at registration with an error naming them rather than mis-scanned: **Extended mode** — the `x` flag, via `(?x)`, `(?x:...)`, or `x` among a set that enables it like `(?imx:...)`. In extended mode an unescaped `#` starts a comment running to the end of the line, so a `}` after one would stop closing the placeholder; unlike every construct in the table above, whether the mode is on at a given point is flag *scope* rather than something with a fixed opener and closer. A route constraint is a single fragment, with no real need for the whitespace and comments extended mode exists to allow. Only flags a run actually *enables* count — everything after a `-` is being switched off, so `(?-x:...)` and `(?im-sx:...)` register and match normally. **Control verbs** — anything spelled `(*...)`, such as `(*MARK:name)` or `(*atomic:...)`. Their shape varies by verb: some end at their first `)` while others hold a whole nested sub-pattern, so a `}` inside one can't be told apart from the brace closing the placeholder. Use `(?>...)` for atomic grouping; the backtracking verbs have no meaning in a single-fragment constraint. Both exclusions are about a construct being *active*, not about the characters that spell it. The constructs in the table above compose with them exactly as you'd expect: `{value:[(*]}` is a character class matching `(` or `*`, `{value:\Q(?x)\E}` matches that literal text, and `{value:(?#(*)a}` is a comment followed by `a` — none of them turns anything on, and all three register and match. ``` This is purely a routing-time detail: `/orders/{id}` and `/orders/{id:\d+}` are indistinguishable to a client, to `#[Query]`/`#[Body]` binding, and in the generated OpenAPI document — the constraint moves into the path parameter's own `schema.pattern` there, and the path key itself always reads as plain `{id}`, since OpenAPI's own path-templating syntax has no concept of an inline regex. ## Sharing routes across controllers `#[RoutePrefix]` prepends a path segment to every route on a controller. Combined with a trait, that lets one set of route methods be mounted at a different path by each controller that uses it: ```{code-block} php trait CrudRoutes { #[Get('/')] public function index(): array { ... } #[Get('/{id}')] public function show(int $id): array { ... } } #[RoutePrefix('/users')] final class UserController { use CrudRoutes; } #[RoutePrefix('/orders')] final class OrderController { use CrudRoutes; } ``` That registers `/users`, `/users/{id}`, `/orders` and `/orders/{id}`. A route declaring `/` sits at the prefix itself, which is what `UserController::index()` above does. **Every declared path must start with `/`** — a route path is absolute, so `#[Get('users')]` is a typo rather than a shorthand and is rejected at registration, as is `#[RoutePrefix('users')]`. The empty string is rejected for the same reason: it would resolve to `/` and quietly claim the root route, which is almost never what someone leaving a path blank meant. Trailing slashes, by contrast, are normalised away, so every path is stored in one canonical form. `#[Get('/users')]` and `#[Get('/users/')]` are the same route, and declaring both is a duplicate rather than two routes each answering half the requests you'd expect. `/` itself is unchanged. The request path goes through the same rule, so a request for `/users/` reaches a route registered as `/users` and binds path parameters exactly as it would without the slash. Both URLs serve the response directly rather than redirecting; if you'd rather have a `301` to the canonical form — for search engines, say — that belongs in front of the application. The prefix is resolved when the route is registered, so everything downstream sees the finished path: duplicate detection, the compiled cache, the OpenAPI document and `kinetis routes:list`. Two controllers sharing one trait under different prefixes therefore don't collide, while two under the *same* prefix are rejected as duplicates, exactly as if the paths had been written out by hand. A trait is the way to share route methods — not a base class. An attribute is only ever read from the class it is written on, so a routed method inherited from a parent is rejected at registration; see [Where attributes are read from](cli.md#where-attributes-are-read-from). ## Parameter binding A controller method's parameters are resolved from six 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; without one, a nullable parameter receives `null`, and a non-nullable one is a `422` (`is required.`), joining the route's other binding errors in the same response. A value whose shape doesn't match the declared type (an array where a scalar is expected, a non-numeric string for `int`/`float`) is also a `422`, not a silently wrong cast — see [Scalar type checking](#scalar-type-checking) below. Constraint attributes (`#[GreaterThan]`, `#[In]`, ...) work here too, the same as on a `#[Body]` DTO field: ```{code-block} php #[Get('/users')] public function index( #[Query] #[GreaterThan(0)] int $page = 1, #[Query] #[In(['asc', 'desc'])] string $sort = 'asc', ) ``` ### 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 — with the identical type-mismatch check and Constraint-attribute support `#[Query]` above describes; a path segment that doesn't match the declared type or fails its own constraint is a `422`, not a value silently coerced to something like `0`. ```{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 request without the expected file resolves like a missing `#[Query]` value: the parameter's default if it has one, `null` if its type allows null, and a `422` (`is required.`) otherwise. ### Class-typed parameters: services and request context A class-typed parameter matching none of the above is resolved from the request container — checked last, so it can never shadow `#[Body]`, `#[Query]`, or a path placeholder. This is what lets one controller serve both a public route and a guarded one. A constructor is shared by every route on its class, so naming a middleware-registered value there would demand it on routes that never run that middleware; a method signature is per route: ```{code-block} php final readonly class ReportController { #[Get('/reports/public')] public function teaser(): array { return ['sample' => true]; } #[Get('/reports/private')] #[Middleware(BearerAuthMiddleware::class)] public function full(CurrentUserInterface $user): array { return ['userId' => $user->id()]; } } ``` Anything the container can supply works the same way — a repository, a `MailerInterface`, whatever a package bootstrap bound — which also means a dependency only one route needs is only built for that route, instead of on every request to the class. If the container cannot supply it, the failure surfaces: a route that forgot the middleware meant to register the value fails loudly rather than handing the controller something disconnected. Give the parameter a default to say that absence is acceptable instead: ```{code-block} php #[Get('/reports/maybe')] public function maybe(?CurrentUserInterface $user = null): array { return ['signedIn' => $user !== null]; } ``` A default has to be written out even when the type is nullable — unlike `#[Query]` and path parameters, where a nullable type alone is enough. Absence means something different here: for those, a missing value is ordinary input variation, while a value the container cannot supply is usually a route missing its middleware. Writing the default is how you say which of the two you meant. The default covers genuine absence only. A service that *was* registered and then failed to construct, or a dependency cycle, is a defect rather than an absent value, so it is reported rather than quietly arriving as `null`. ```{note} This applies to HTTP controllers. An MCP tool's arguments arrive as one flat object, so a class-typed parameter there is a DTO hydrated from those arguments — see {doc}`mcp`. ``` A parameter matching none of the six — untyped, or scalar-typed with no attribute and no matching placeholder — falls back to its default value if it has one, and otherwise fails with an error naming every source it could have come from, 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. It documents the statuses *besides* the route's own. The route attribute already declares that one — `200` unless you set `status:` — and the generator describes it from the method's return type, response schema included. An attribute repeating that status is ignored rather than overwriting the richer entry with a bare description, so there is no way to accidentally strip a route's own response schema out of the document. ## 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\PlainTextResponse; use Kinetis\Http\Responses\RedirectResponse; use Psr\Http\Message\ResponseInterface; final readonly class PagesController { #[Get('/welcome')] public function welcome(): ResponseInterface { return HtmlResponse::create('