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

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:

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

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

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:

#[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]/UploadedFileInterface read from here.