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¶
<?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, soGET /articles/abcreaches the route and fails with422rather than404. There is no inline pattern such as{id:\d+}.The most specific route wins whatever the declaration order:
/articles/latestbeats/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 a404, and no controller runs. Text that passes still binds and validates as usual, so a type or validation failure stays a422.A constraint may admit
/:.*capturesguides/clifrom/docs/guides/cli./docs/is the same path as/docs, which has no tail, so declare/docsseparately 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 |
the PSR-7 request |
typed |
the uploaded file with the parameter’s name |
|
a DTO hydrated and validated from the request body |
|
the query-string value with the parameter’s name |
named like a |
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:
ascbindsSortDirection::Ascending, andASCdoes not. Text naming no case is a422with codeenum_case, listing every backing value.An
int-backed enum resolves the text as an integer first, so?priority=urgentis the ordinary not-an-integer422rather than an unknown-case one.A missing query value takes the parameter’s default, then
nullfor 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:
|
Read as |
|---|---|
|
a JSON object |
|
form fields |
|
form fields and uploaded files |
A nonblank body under any other media type, or without a
Content-Type, is refused with415. A route that accepts arbitrary bytes — a signed webhook, a binary upload — takes aServerRequestInterfaceparameter and reads the body itself. Generic JSON is not placed ingetParsedBody(): 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 with413before 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:
<?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 $summarywith no default would still be required, and would accept an explicitnull.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 |
|
Numbers |
|
Choices |
|
Lists |
|
Uploaded files |
|
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_caseviolation listing the valid values.arrayhas no element type, so a list declares one with#[ListOf]: a scalar type name such as'string', a backed enum, a DTO class, orUploadedFileInterface.#[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,
nullorAbsent::Value, exactly as for a text field.An upload that failed in transfer is one
upload_failedviolation, and no constraint runs on it.A repeated control,
photos[], binds to#[ListOf(UploadedFileInterface::class)] array $photos.Form bodies are bounded by
MAX_BODY_SIZEand byKinetis\Http\Form\FormLimits— field count, file count, nesting depth. A form past any limit is refused whole with413; 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.jsonserves the document;GET /openapiserves Swagger UI for it.
Both are off until you name the environments that serve them:
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 arequestBodyschema, stored once per class undercomponents/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@groupreference expanded exactly as it is for dispatch — becomes that operation’ssecurity, 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¶
Middleware — authentication, CORS, rate limits and the validation response around your routes.
Appendix: Routing & Validation — the complete matching, binding, validation and OpenAPI rules.
Appendix: Container Lifecycle — how controller constructor dependencies are resolved.
Testing — sending requests to these routes from a test.
Caching & AOT Compilation — how routes and validation plans are compiled for production.