Routing & Validation

Routes, request binding and validation are attributes on your controller classes and request DTOs. There is no route file to keep in sync. This page covers the everyday path; Appendix: Routing & Validation holds the complete matching, binding, validation and schema rules.

A first controller

src/Http/ArticleController.php
<?php

declare(strict_types=1);

namespace App\Http;

use Kinetis\Http\Attributes\{Body, Get, Post};

final readonly class ArticleController
{
    public function __construct(
        private ArticleRepository $articles,
    ) {}

    #[Get('/articles/{id}')]
    public function show(int $id): ArticleResponse
    {
        return $this->articles->get($id);
    }

    #[Post('/articles', status: 201)]
    public function create(#[Body] CreateArticleRequest $request): ArticleResponse
    {
        return $this->articles->create($request);
    }
}

ArticleRepository and ArticleResponse are application classes; CreateArticleRequest is defined in Validating input.

Nothing registers this controller. Any class under one of your project’s production autoload.psr-4 roots joins the route table as soon as one of its methods carries a route attribute; CLI covers restricting that scan in a large application. Methods without a route attribute are ordinary helpers.

#[Get], #[Post], #[Put], #[Patch] and #[Delete] each take a path, an optional status, which defaults to 200, and optional route constraints. A controller that returns an array or an object is answered with it as JSON at that status; Responses covers returning anything else.

  • A path starts with /. #[Get('articles')] is rejected when the route registers.

  • A trailing slash is ignored, in the declaration and in the request: /articles/ reaches /articles.

  • Without a route constraint, {id} captures one path segment. The parameter’s type and constraints decide what the value may be, so GET /articles/abc reaches the route and fails with 422 rather than 404. There is no inline pattern such as {id:\d+}.

  • The most specific route wins whatever the declaration order: /articles/latest beats /articles/{id}. Two routes with the same method and path shape are rejected at registration, whatever their route constraints.

kinetis routes:list prints the resulting table. Matching order, the placeholder grammar and registration failures are in Route registration and matching.

Route constraints

where decides what text a placeholder admits. Each entry maps a placeholder to a self-contained PCRE2 fragment, written without delimiters or anchors, that the placeholder’s text must match whole:

#[Get('/articles/{slug}', where: ['slug' => '[a-z0-9-]+'])]
public function show(string $slug): ArticleResponse

#[Get('/docs/{page}', where: ['page' => '.*'])]
public function page(string $page): HtmlResponse
  • Text that fails a constraint is a route miss: GET /articles/Hello! is a 404, and no controller runs. Text that passes still binds and validates as usual, so a type or validation failure stays a 422.

  • A constraint may admit /: .* captures guides/cli from /docs/guides/cli. /docs/ is the same path as /docs, which has no tail, so declare /docs separately if it needs an answer.

  • A captured tail is raw request text and may hold // or ... Validate and canonicalise it before touching the filesystem.

  • A constraint changes neither which route wins nor what counts as a duplicate.

An invalid map fails when the route registers: a key naming no placeholder, an empty fragment, a fragment that does not compile on its own or leaves a group open or closes one it did not open, or one that uses (*ACCEPT). The generated document publishes each fragment as an x-kinetis-route-constraint extension on its path parameter. Route constraints has the complete rules.

Binding request values

Each controller parameter takes its value from the first source that claims it:

Parameter

Receives

typed ServerRequestInterface

the PSR-7 request

typed UploadedFileInterface

the uploaded file with the parameter’s name

#[Body]

a DTO hydrated and validated from the request body

#[Query]

the query-string value with the parameter’s name

named like a {placeholder}

the text that placeholder captured

any other class type

a service from the request container

Binding runs before the controller is constructed, so a request that fails it never reaches your code. A parameter no source claims uses its default, or fails with an error naming every source it could have come from.

Path and query values

use Kinetis\Http\Attributes\{Get, Query};
use Kinetis\Validation\Constraints\{GreaterThan, In};

#[Get('/authors/{authorId}/articles')]
public function byAuthor(
    #[GreaterThan(0)] int $authorId,
    #[Query] #[GreaterThan(0)] int $page = 1,
    #[Query] #[In(['newest', 'oldest'])] string $sort = 'newest',
    #[Query] ?string $tag = null,
): array

Path segments and query values are text, and bind only when the text spells the declared type: "42" binds an int, while "4.5" and "abc" do not, and a bool accepts true, false, 1 and 0. A value of the wrong type, or one that fails a constraint, is a 422 naming the parameter. A missing query value takes the parameter’s default, then null for a nullable type; a parameter with neither is required.

#[Query] takes no arguments: the parameter name is the query key. An array parameter reads a repeated key, ?tag=php&tag=http. PHP’s bracket spelling, ?tag[]=php, is a different key and does not bind. A path parameter is always one string, never an array, even when a route constraint lets it span /. See Query and path values are raw strings.

Enum path and query values

A #[Query] or path parameter may also be typed as a backed enum. The text is read as the enum’s backing type and then matched against its cases:

use Kinetis\Http\Attributes\{Get, Query};

enum SortDirection: string
{
    case Ascending = 'asc';
    case Descending = 'desc';
}

#[Get('/articles/{direction}')]
public function index(
    SortDirection $direction,
    #[Query] ?SortDirection $secondary = null,
): array
  • The match is exact: asc binds SortDirection::Ascending, and ASC does not. Text naming no case is a 422 with code enum_case, listing every backing value.

  • An int-backed enum resolves the text as an integer first, so ?priority=urgent is the ordinary not-an-integer 422 rather than an unknown-case one.

  • A missing query value takes the parameter’s default, then null for a nullable type, exactly as for a scalar. A path segment is always present.

  • The generated document publishes the backing type and the exact set of case values, so a generated client cannot send what the route rejects.

A backed enum is the only class a query string or a path segment can carry, since both carry text. A unit enum has no backing values, and a DTO is a document, so either one — or any other class type on a #[Query]/path parameter — fails at route registration rather than at request time. Move such a parameter to #[Body], where a DTO field may declare a class type. See Enum path and query parameters.

The request body

#[Body] builds the parameter’s class from the request body and validates it before the controller runs, as Validating input shows. The body is read according to its Content-Type:

Content-Type

Read as

application/json, or an application/*+json subtype

a JSON object

application/x-www-form-urlencoded

form fields

multipart/form-data

form fields and uploaded files

  • A nonblank body under any other media type, or without a Content-Type, is refused with 415. A route that accepts arbitrary bytes — a signed webhook, a binary upload — takes a ServerRequestInterface parameter and reads the body itself. Generic JSON is not placed in getParsedBody(): a route that bypasses #[Body] decodes the staged body stream itself.

  • A JSON body that is not valid JSON, or not an object, is a 400. An empty body counts as {}.

  • A body larger than MAX_BODY_SIZE, 2 MiB unless you change it, is refused with 413 before routing. See Request body limits.

JSON values carry their own types, while form fields are text. One DTO accepts both encodings, but a JSON body must send 42 for an int field and true for a bool: the strings "42" and "true" are type errors in JSON and ordinary values in a form. See Scalar type checking.

When the payload is wrapped in one member, {"article": {...}}, name that member: #[Body('article')]. A method has at most one #[Body] parameter. See Reading the DTO from one top-level member.

Services and the current user

A class-typed parameter matching no other source comes from the request container: a repository, a mailer, or a value a route middleware registered for this request.

The authentication example below uses the optional kinetis/auth package. Install and configure its user provider as shown in Authentication before guarding the route.

use Kinetis\Auth\BearerAuthMiddleware;
use Kinetis\Http\Attributes\{Get, Middleware};
use Kinetis\Http\CurrentUserInterface;

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()];
    }
}

A dependency declared on the method belongs to that route alone. The constructor is shared by both routes, and only the guarded one has a current user. When nothing registered the value, the request fails instead of passing null; declare ?CurrentUserInterface $user = null where absence is acceptable. Middleware shows how a middleware registers the value, and Authentication provides BearerAuthMiddleware.

Validating input

A request DTO is a class whose constructor parameters are the fields a client sends, with constraint attributes as the rules:

src/Http/CreateArticleRequest.php
<?php

declare(strict_types=1);

namespace App\Http;

use Kinetis\Validation\Constraints\{Email, MaxLength, MinLength, NotBlank};

final readonly class CreateArticleRequest
{
    public function __construct(
        #[NotBlank, MaxLength(200)]
        public string $title,
        #[MinLength(20)]
        public string $body,
        #[Email]
        public string $authorEmail,
        public ?string $summary = null,
        public bool $published = false,
    ) {}
}

The create() action at the top of this page binds it with #[Body] CreateArticleRequest $request. From that one declaration:

  • A field without a default is required; a field with one may be omitted. ?string $summary with no default would still be required, and would accept an explicit null.

  • Every field is checked, and every failure is reported in one response.

  • The controller is not constructed for a request that fails validation.

  • A JSON member the DTO does not declare is rejected as unexpected_field, which catches a misspelled field name. Form bodies accept extra fields such as a CSRF token.

The 422 response

A request that fails validation receives an RFC 9457 problem document, served as application/problem+json:

{
    "type": "about:blank",
    "title": "Unprocessable Content",
    "status": 422,
    "detail": "The request data failed validation.",
    "errors": [
        {
            "path": ["title"],
            "code": "required",
            "message": "is required.",
            "parameters": {}
        },
        {
            "path": ["body"],
            "code": "min_length",
            "message": "must be at least 20 characters.",
            "parameters": {"length": 20}
        }
    ]
}

Each error carries:

  • path — the segments leading to the failing value, where a member name is a string and a list index an integer: ["lines", 1, "quantity"];

  • code — a stable identifier for a client to switch on;

  • message — default English text;

  • parameters — the values the message was built from, for translation.

A path or query parameter that fails its type or a constraint is reported the same way. To answer with something else, such as a redirect back to an HTML form, replace the renderer: see Rendering validation failures.

Constraints

The built-in constraints live in Kinetis\Validation\Constraints and apply to DTO fields, path parameters and query parameters alike:

Values

Constraints

Strings

NotBlank, MinLength, MaxLength, Regex, Email, Url, Uuid, Ip, Date, DateTime

Numbers

GreaterThan, GreaterThanOrEqual, LessThan, LessThanOrEqual, MultipleOf

Choices

In, NotIn

Lists

MinItems, MaxItems

Uploaded files

FileSize, FileExtension

Stack constraints for a range: #[GreaterThan(0)] #[LessThan(100)] int $percentage. The declared PHP type is checked first, so a constraint only runs against a value of that type. Each constraint’s exact check, code and published schema keyword are in Validation constraints. A rule of your own implements Kinetis\Validation\Constraint; see Writing your own constraint.

Nested objects, lists and enums

use Kinetis\Validation\Constraints\{GreaterThan, MinItems};
use Kinetis\Validation\ListOf;

enum Shipping: string
{
    case Standard = 'standard';
    case Express = 'express';
}

final readonly class OrderLine
{
    public function __construct(
        public string $sku,
        #[GreaterThan(0)]
        public int $quantity,
    ) {}
}

final readonly class CreateOrderRequest
{
    public function __construct(
        public Shipping $shipping,
        #[ListOf(OrderLine::class)]
        #[MinItems(1)]
        public array $lines,
    ) {}
}
{
    "shipping": "express",
    "lines": [{ "sku": "BOOK-1", "quantity": 2 }]
}
  • A field typed as another class is a nested DTO, validated the same way. Its failures carry the full path, such as ["lines", 0, "quantity"].

  • A backed-enum field takes the case its backing value names; any other value is an enum_case violation listing the valid values.

  • array has no element type, so a list declares one with #[ListOf]: a scalar type name such as 'string', a backed enum, a DTO class, or UploadedFileInterface. #[Each] runs a constraint on every element of a scalar, enum or file list.

  • A field that accepts a JSON object with arbitrary keys is declared #[ObjectMap] array.

The reference covers nested DTOs, typed collections, backed enum fields and object maps.

Partial updates

A PATCH must tell a field the client left out from a field it cleared. Absent is that third state:

use Kinetis\Validation\Absent;
use Kinetis\Validation\Constraints\{MaxLength, NotBlank};
use Kinetis\Validation\ObjectConstraints\AtLeastOneProvided;

#[AtLeastOneProvided('title', 'summary')]
final readonly class UpdateArticleRequest
{
    public function __construct(
        #[NotBlank, MaxLength(200)]
        public string|Absent $title = Absent::Value,
        public string|null|Absent $summary = Absent::Value,
    ) {}
}

Absent::Value means the member was not sent; null means the client cleared it. #[AtLeastOneProvided] rejects an update that names no field. Keep create and update DTOs as separate classes. See Required, optional, and absent fields and Rules about the whole DTO.

File uploads

A multipart/form-data request binds a file to an UploadedFileInterface field of a #[Body] DTO, or to a controller parameter named like the file control:

use Kinetis\Http\Attributes\{Body, Post};
use Kinetis\Validation\Constraints\{FileExtension, FileSize, NotBlank};
use Psr\Http\Message\UploadedFileInterface;

final readonly class AvatarUploadRequest
{
    public function __construct(
        #[NotBlank]
        public string $displayName,
        #[FileSize(maxBytes: 1_000_000)]
        #[FileExtension(['png', 'jpg'])]
        public UploadedFileInterface $avatar,
    ) {}
}

#[Post('/avatars')]
public function upload(#[Body] AvatarUploadRequest $request): array
{
    return ['size' => $request->avatar->getSize()];
}

Warning

#[FileExtension] checks the filename the client chose, and #[FileSize] the size the upload reported. Neither reads the file, and Kinetis never validates upload contents. Treat both the name and the bytes as untrusted: check the content where your code reads it, and do not use the client filename as a storage path.

Warning

#[FileSize] bounds one uploaded part. MAX_BODY_SIZE (see Request body limits) bounds the complete encoded request that carries it — every field and file, plus the multipart framing between them, including the boundary the client chose. That framing has no fixed size, so a #[FileSize(maxBytes: ...)] declared without enough headroom below MAX_BODY_SIZE can be unreachable: the envelope pushes the request over the body cap first, and the request is refused with 413 before routing runs, not with FileSize’s 422. Leave enough headroom below MAX_BODY_SIZE for the rest of the form and for an envelope whose exact size you do not control.

  • A file control the user left empty counts as an omitted field: required, defaulted, null or Absent::Value, exactly as for a text field.

  • An upload that failed in transfer is one upload_failed violation, and no constraint runs on it.

  • A repeated control, photos[], binds to #[ListOf(UploadedFileInterface::class)] array $photos.

  • Form bodies are bounded by MAX_BODY_SIZE and by Kinetis\Http\Form\FormLimits — field count, file count, nesting depth. A form past any limit is refused whole with 413; see Runtime Adapters’s “Request bodies: one contract under every runtime”.

The generated document describes a DTO that declares an upload as multipart/form-data only. Nested file controls and index handling are in Uploads; storing the file is covered in Storage.

Responses

Status codes and errors

The route’s status applies when the controller returns data. For any other status, return a PSR-7 ResponseInterface, which Kinetis sends unchanged:

use Kinetis\Http\Attributes\{Get, Response};
use Kinetis\Http\Responses\ErrorResponse;
use Psr\Http\Message\ResponseInterface;

#[Get('/articles/{id}')]
#[Response(404, description: 'Article not found.')]
public function show(int $id): ResponseInterface|ArticleResponse
{
    $article = $this->articles->find($id);

    if ($article === null) {
        return ErrorResponse::create(404, "Article {$id} not found.");
    }

    return $article;
}

ErrorResponse::create() produces the returned 404, as {"error": "Article 7 not found."} — the shape of Kinetis’s own 404, 405 and 500 responses. #[Response] only documents the extra status in the OpenAPI document; nothing checks that the method returns it.

Documenting an error payload

#[Response] takes an optional body naming the DTO that status returns, and an optional mediaType, application/json unless it says otherwise:

use Kinetis\Http\Attributes\{Get, Response};

#[Get('/articles/{id}')]
#[Response(404, description: 'Article not found.', body: ApiError::class)]
#[Response(503, description: 'Temporarily unavailable.')]
public function show(int $id): ResponseInterface|ArticleResponse

The named DTO is published as a component schema under the declared media type, and one DTO named by two statuses is one component referenced twice. Without a body the entry stays description-only, and mediaType then describes nothing and is ignored. A body naming something that is not a class fails document generation rather than advertising a shape no response can have.

This stays descriptive: nothing enforces the status, the schema or the media type at runtime, and Kinetis declares no 422 for you — document the validation response with a #[Response(422, ...)] of your own where a route should advertise it. An attribute repeating the route’s own status is ignored, since the generator already describes that status from the method’s return type.

An exception can carry its own status instead; see Mapping your own exceptions to a status.

JSON, HTML, text, files and redirects

use Kinetis\Http\Attributes\Get;
use Kinetis\Http\Responses\{FileResponse, HtmlResponse, JsonResponse, PlainTextResponse, RedirectResponse};
use Psr\Http\Message\ResponseInterface;

final readonly class PagesController
{
    #[Get('/status')]
    public function status(): ResponseInterface
    {
        return JsonResponse::create(
            ['ready' => true],
            headers: ['Cache-Control' => 'no-store'],
        );
    }

    #[Get('/welcome')]
    public function welcome(): ResponseInterface
    {
        return HtmlResponse::create('<h1>Welcome</h1>');
    }

    #[Get('/reports/{id}.csv')]
    public function report(int $id): ResponseInterface
    {
        return FileResponse::fromContents(
            "id,total\n{$id},42\n",
            'text/csv',
            downloadFilename: "report-{$id}.csv",
        );
    }

    #[Get('/old-url')]
    public function oldUrl(): ResponseInterface
    {
        return RedirectResponse::to('/new-url', 301);
    }

    #[Get('/robots.txt')]
    public function robots(): ResponseInterface
    {
        return PlainTextResponse::create("User-agent: *\nDisallow:\n");
    }
}

JsonResponse::create(mixed $data, int $status = 200, array $headers = []) uses the same strict JSON encoding as an array or DTO returned directly from a controller. Invalid UTF-8, an unsupported value or a failing JsonSerializable throws before a response exists; Kinetis does not silently replace application data. Content-Type: application/json always replaces a caller-supplied content type, including a differently cased header name.

Use the helper when the runtime status or headers belong to the result. A returned ResponseInterface passes through untouched, so OpenAPI cannot infer its body. Keep the DTO in the method’s return union for the route’s default response, and describe each alternate response with #[Response(..., body: YourResponse::class)].

HtmlResponse does not escape anything: escape user input yourself, or render through Views.

No response builder takes a filesystem path, because a synchronous read holds the worker for the duration of the I/O:

  • files shipped with the release — CSS, images, downloads — are served by the web server in front of the application;

  • files the application stores are read through Storage, and the bytes are passed to FileResponse::fromContents();

  • a body too large to hold in memory is written by returning a Kinetis\Http\StreamedResponse.

downloadFilename is escaped for the Content-Disposition header, so a user-supplied name cannot add header parameters. Path separators are kept; call basename() when the name comes from a stored path. Builder signatures and the filename encoding are in Responses.

OpenAPI documentation

Kinetis generates an OpenAPI 3.1 document from the same attributes:

  • GET /openapi.json serves the document;

  • GET /openapi serves Swagger UI for it.

Both are off until you name the environments that serve them:

.env
OPENAPI_ENVIRONMENTS=development,staging

Each value is compared with APP_ENV, ignoring case. When OPENAPI_ENVIRONMENTS is unset or names no running environment, both paths answer 404. The document describes your whole route table, so name an environment only where that is acceptable, and add middleware for these two paths with #[AsOpenApiMiddleware] where they need protection (see Middleware groups).

The document contains:

  • every route, with its path and query parameters and their constraints as schema keywords;

  • each #[Body] DTO as a requestBody schema, stored once per class under components/schemas;

  • the method’s return type as the default response, and each #[Response] as an additional status.

#[Regex], #[NotBlank], #[FileSize] and #[FileExtension] have no JSON Schema equivalent. They are enforced, but not published, so the document accepts more than the route does.

#[Hidden] leaves a route out of the document without changing how it runs. On a controller class it hides every route of that class:

use Kinetis\Http\Attributes\{Get, Hidden};

#[Hidden]
final readonly class InternalController
{
    #[Get('/internal/status')]
    public function status(): array { /* ... */ }
}

In development the document is rebuilt on every request; in production it is built once per process. The schema mapping is in OpenAPI generation.

Authentication in the document

Middleware that implements Kinetis\OpenApi\SecurityDescriberInterface states the authentication it enforces, and the document is built from that. The middleware in Authentication and JWT Authentication already does; your own adds one static method:

use Kinetis\OpenApi\SecurityDescriberInterface;
use Kinetis\OpenApi\SecurityDescription;

final class ApiKeyMiddleware implements MiddlewareInterface, SecurityDescriberInterface
{
    public static function openApiSecurity(): SecurityDescription
    {
        return SecurityDescription::scheme('apiKey', [
            'type' => 'apiKey',
            'in' => 'header',
            'name' => 'X-Api-Key',
        ]);
    }

    // process() as usual.
}

The method is read without the middleware being constructed, so it must be pure: no I/O, no request, no container, no configuration. It describes the wire mechanism the class implements, which a deployment’s own credentials and policy do not change. The definition is a raw OpenAPI 3.1 security scheme; its type must be one of apiKey, http, mutualTLS, oauth2 and openIdConnect.

Where the middleware runs decides where the requirement lands:

  • global middleware becomes the document’s root security, which every operation inherits;

  • a route’s own #[Middleware] — a class, or a @group reference expanded exactly as it is for dispatch — becomes that operation’s security, published where it differs from the root;

  • a subclass inherits the description, so the thin class that carries #[AsMiddlewareGroup] documents every route referencing that group.

Middleware that does not implement the interface leaves the document unchanged, and nothing is inferred from a controller’s own body.

Middleware in sequence is AND: two describers around one route produce one requirement object naming both schemes. One description holding several requirement objects is OR, and an empty requirement object is an alternative that carries no credential at all — a route that answers anonymous and authenticated requests alike publishes both:

"security": [{"apiKey": []}, {}]

Two classes may publish the same scheme name only when they define it identically — the order the members are written in is not a difference. Two different definitions under one name fail generation, naming the scheme and both classes.

Declaring an operation’s security yourself

#[OpenApiSecurity] on a controller class or a route method replaces the inference for that operation:

use Kinetis\Http\Attributes\OpenApiSecurity;

#[Get('/reports/{id}')]
#[OpenApiSecurity(ApiKeyMiddleware::class)]
public function show(int $id): array { /* ... */ }

A method declaration wins over a class one, and a class one wins over inference; neither is read from a parent class. Each argument is a provider class, and several are required together — alternatives belong inside one provider’s description, not in a second attribute.

With no argument it publishes security: [], which drops the root security the operation would otherwise inherit:

#[Get('/health')]
#[OpenApiSecurity]
public function health(): array { /* ... */ }

The attribute changes the document and nothing else. Every middleware still runs exactly as declared, so a no-argument declaration describes a route whose pipeline already admits an unauthenticated request — it does not make a guarded route reachable, and a middleware that answers 401 still does. Use it where inference cannot see the truth: a controller that authenticates in its own body, and middleware that describes a requirement it does not impose on this route. A controller parsing the Authorization header itself should read it through Kinetis\Http\Auth\AuthorizationToken68Parser (The Authorization header) — the same public parser both bearer middleware packages use — rather than reimplementing the grammar.

No response status follows from any of this. A 401 or 403 a route can answer with is documented by #[Response], like every other status. The composition and validation rules are in Security.

Grouping routes under a prefix

#[RoutePrefix] prepends a path to every route of a controller. Combined with a trait, one set of route methods can be mounted at a different path by each controller that uses it:

use Kinetis\Http\Attributes\{Get, RoutePrefix};

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}. Share route methods through a trait, not a base class: a routed method inherited from a parent is rejected at registration. A middleware class can also carry a prefix; see Sharing routes across controllers.

See also