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¶
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.
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
Getting Started and 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
below) before the controller method ever runs.
#[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.
#[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.
#[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.
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
below.
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¶
#[Body] isn’t limited to JSON. Dispatcher picks how to read the body
from the request’s Content-Type:
Content-Type |
Read from |
|---|---|
|
|
|
|
|
|
A #[Body] DTO can mix ordinary fields with an UploadedFileInterface-typed
constructor parameter — no special handling needed in the DTO itself:
use Psr\Http\Message\UploadedFileInterface;
final readonly class AvatarUploadRequest
{
public function __construct(
public string $name,
public UploadedFileInterface $avatar,
) {}
}
#[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:
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 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:
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 —Dispatchersees aResponseInterfaceand 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 below.Dispatchernever reads it; onlyOpenApiGeneratordoes. 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:
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('<h1>Welcome</h1>');
}
#[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)setsContent-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$contentTypeis 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 aContent-Disposition: attachmentheader when$downloadFilenameis given.RedirectResponse::to(string $url, int $status = 302)sets aLocationheader.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:
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 |
|---|---|---|
|
|
(no arguments) |
|
not empty or all-whitespace after |
(no arguments) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(no arguments) |
|
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:
#[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:
{
"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:
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:
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:
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,
) {}
}
{
"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:
{
"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
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 Caching & AOT Compilation’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:
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,
) {}
}
{
"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:
{
"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 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] element, at any depth — is
deduplicated into components/schemas and referenced by $ref, rather
than inlined at each point of use:
{
"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
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:
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:
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('<h1>Dashboard</h1>');
}
}
#[Hidden] on a controller class hides every route on it at once, for a
controller that shouldn’t appear in the document at all:
#[Hidden]
final readonly class InternalController
{
#[Get('/internal/status')]
public function status(): array { /* ... */ }
}
See also¶
Container — how a controller’s own constructor dependencies get resolved.
Middleware —
#[Middleware], the same attribute-driven pattern applied to wrapping a route rather than binding its parameters.Caching & AOT Compilation — 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.
Runtime Adapters — how each runtime gets a request’s multipart body into the uploaded-files bag
#[Body]/UploadedFileInterfaceread from here.