HTTP and kernel¶
Namespaces Keel\Http and Keel\Kernel. PSR-7 messages come from nyholm/psr7; middleware is
PSR-15, factories are PSR-17. The kernel is one object per worker and holds no request state.
Request pipeline¶
ExceptionHandlerMiddleware → global middleware → RoutingMiddleware
→ route middleware (class, then method) → controller → Responder
$kernel = Kernel::build(
$definitions, // Keel\Container\Definitions
[UserController::class], // attribute-annotated controllers
[CorsMiddleware::class], // global middleware, in order
debug: $env === 'dev',
);
$response = $kernel->handle($request); // ServerRequestInterface → ResponseInterface
$kernel->terminate($request, $response); // after sending: ResponseSent, deferred tasks, Container::reset()
Kernel::build() registers Psr17Factory under every PSR-17 interface, Responder,
ArgumentResolver and Router in the container unless the application already did. With a
compiled container and a route cache use the constructor
new Kernel($container, $router, $middleware, $debug); call Kernel::registerDefaults()
before compiling. terminate() dispatches ResponseSent, runs the DeferredTasks and resets
the services; see Runtimes.
Pipeline is a PSR-15 handler: middleware run in declaration order (FIFO); class names are
resolved from the container on every request, so the container owns their lifetime: a shared
service is one instance for the whole worker (implement Resettable to drop request state), a
transient one is fresh for each request. A [class, arguments] pair (what #[MiddlewareWith]
records, also allowed in HttpConfig) is built once by the pipeline with the named arguments
and the rest autowired. A pipeline instance holds no request state and is reused across
requests.
Handler arguments¶
ArgumentResolver fills the controller method parameters, for each parameter in this order:
- type
ServerRequestInterface,RouteMatch,RouteorInput: the request, the routing result or the request reader; #[FromQuery]: a DTO is hydrated from all query parameters; a scalar is read from the query parameter with the argument's name (missing without a default: 400);#[FromBody]: JSON (application/json,*+json) orgetParsedBody(); a DTO is hydrated, anarrayreceives the body as is; malformed JSON: 400. On a scalar parameter (string,int, ...) the attribute is a configuration error:LogicExceptionon the first request;#[FromHeader('X-Request-Id')]and#[FromCookie](the cookie named like the parameter, or#[FromCookie('theme')]) fill a scalar the same way: cast to the type (400 on mismatch), the default ornullwhen absent, 400 without either;#[FromFile]binds an upload, see File storage;- a route placeholder with the parameter's name, cast to the type (
int,float,bool,string, enum); a mismatch: 400; - a service from the container by type (
has()); - the default value, or
nullfor a nullable type; - otherwise
LogicException(a configuration error, answered with 500).
Input¶
Keel\Http\Input, injected by type, is the convenient read over the PSR-7 request for the odd
parameter and for middleware; DTOs with #[FromQuery] and #[FromBody] stay the typed way in.
#[Post('/search')]
public function search(Input $input): array
{
$q = $input->string('q', ''); // query and body merged, body wins
$page = $input->int('page', 1); // cast like the hydrator; "abc" is 400
$city = $input->get('address.city'); // dotted paths into nested data
$tags = $input->array('tags', []);
$token = $input->bearerToken();
$ip = $input->ip(); // from TrustedProxiesMiddleware, else REMOTE_ADDR
// ...
}
all(), get($key, $default), has(), filled() (not null, "" or []), only(), except(),
query(), string()/int()/float()/bool()/array() with a default for an absent key,
param($name) (a route placeholder), header(), cookie(), file()/files() (uploads),
bearerToken(), ip(), method(), path(), isSecure(), isJson() (JSON body),
wantsJson() (JSON first in Accept, or a JSON body without Accept), isAjax(), and
request() for everything else. The body is read on first use and only for methods other than
GET and HEAD; malformed JSON is 400.
Hydrator¶
Hydrator::hydrate(Dto::class, array $data) calls the DTO constructor with named values:
it casts scalars ('31' → 31, 'true' → true), enums by value or case name, nested DTOs
from arrays, union types by trial, and applies defaults and null. Every problem is collected
and reported as one BadRequestException with an errors field:
["email: field is required", "address.zip: must be a string"].
A DTO constructor may reject a combination of values that no single rule can express by throwing
InvalidArgumentException or DomainException: the message becomes one more error, prefixed with
the field path for a nested DTO (range: end must not be before start). Any other exception from a
constructor is a bug in the DTO and answers 500.
JSON bodies are decoded with PHP's default nesting limit of 512 levels; a deeper document is a 400
("Maximum stack depth exceeded"). The body size is not capped by the framework: set
post_max_size, client_max_body_size or the equivalent of your server in front of it.
Integers must fit in int: "99999999999999999999" or 1e20 is "must be an integer", not a
silently saturated value. Union members are tried in a fixed order, int, float, bool,
string, then classes and enums, whatever the declaration order (PHP itself reports int|string
as string|int): for int|string the value "42" becomes 42 and "x" stays a string.
Pagination¶
#[FromQuery] PageRequest $page reads ?page=3&perPage=50 (defaults 1 and 20, perPage at most
100; out of range is a 400 like any other query DTO); offset() and limit() feed the query.
Return a Page and the response is the envelope
{"items": [...], "page": 3, "perPage": 50, "total": 93, "pages": 2}:
/** @return Page<UserView> */
#[Get('/users')]
public function list(#[FromQuery] PageRequest $page): Page
{
return Page::of($this->users->slice($page->offset(), $page->limit()), $page, $this->users->count());
}
@return Page<UserView> (or #[Response(200, type: Page::class, items: UserView::class)]) gives
the OpenAPI document the element type. Subclass PageRequest with your own constructor to change
the defaults or the upper bound.
Responses¶
Responder::respond($result, ?int $status):
| Handler return | Response |
|---|---|
ResponseInterface |
as is (status replaced when #[Status] is set) |
null |
204 without body |
string |
text/html; charset=utf-8 |
Keel\Http\Redirect |
3xx with Location: Redirect::to('/login'), Redirect::route('users.show', ['id' => 5], 303), see Routing |
Keel\Http\EventStream |
text/event-stream, produced while it is sent, see Streaming |
array, JsonSerializable, object |
application/json with JSON_UNESCAPED_UNICODE, JSON_UNESCAPED_SLASHES, JSON_PRESERVE_ZERO_FRACTION |
Streaming and Server-Sent Events¶
#[Get('/orders/{id}/progress')]
public function progress(int $id): EventStream
{
return new EventStream(function () use ($id) {
foreach ($this->tracker->follow($id) as $step) {
yield new SseEvent($step, event: 'progress', id: (string) $step->sequence);
}
yield new SseEvent('done', event: 'end');
});
}
#[Get('/orders/export')]
public function export(Responder $responder): ResponseInterface
{
return $responder->stream(function () {
foreach ($this->orders->cursor() as $order) {
yield json_encode($order) . "\n";
}
}, 'application/x-ndjson');
}
The body is an IteratorStream: nothing runs until the emitter reads it, and every chunk goes to
the client as soon as the generator yields it. EventStream items are SseEvent objects
(data, event, id, retry, comment), strings (sent as data) or anything JSON-encodable;
SseEvent::comment('ping') is the usual heartbeat that keeps proxies from closing an idle
connection. Event streams carry Cache-Control: no-cache and X-Accel-Buffering: no, so nginx
passes events through instead of buffering them.
SapiEmitterflushes PHP's output buffers after every chunk and, onceconnection_aborted()reports the client gone, stops and closes the body, which ends the generator (itsfinallyruns). Keepzlib.output_compressionoff and make sure the proxy does not buffer.RoadRunnerRuntimesends a non-seekable body as a RoadRunner stream, one frame per chunk (RoadRunner 2023.3 or newer); every other body still goes out in one frame.- The status line and headers are sent before the generator runs: an exception it throws is logged by the runtime as critical and cuts the response short. Catch what you can inside.
- An open stream holds a worker (a php-fpm child, a FrankenPHP thread, a RoadRunner worker) until
it ends. Size the pool for the streams you expect and keep capacity for ordinary requests with
LoadSheddingMiddleware(maxInFlight, see Runtimes). HttpCacheMiddleware,#[OutputCache]and idempotency keys leave streamed bodies alone.
Errors: RFC 9457 Problem Details¶
ExceptionHandlerMiddleware is the outermost middleware and turns every exception into
application/problem+json:
{"type":"about:blank","title":"Not Found","status":404,"detail":"No route for GET /nope"}
Keel\Http\Exception\HttpExceptionand its subclasses (BadRequest,Unauthorized,Forbidden,NotFound,MethodNotAllowed,Conflict,ContentTooLarge,UnsupportedMediaType,UnprocessableContent,TooManyRequests,ServiceUnavailable) set the status, headers (such asAllow,WWW-Authenticate) and a client-safe message indetail;errorsis added when present.- Any other exception is a 500 with the title "Internal Server Error" and no
detail; it is logged through theLoggerInterfacefrom the container when one is registered. - In
debugmodedetailand anexceptionobject with class, file, line and trace are added. - When the client prefers
text/htmlover JSON perAccept(higherq, or listed first for an equalq) an HTML page is sent instead of JSON: status and title only in production; in debug the exception chain, the trace, the request withAuthorization/Cookieredacted and a "Copy as Markdown" button. - The body is encoded with
ProblemDetails::JSON_FLAGS(Responder::JSON_FLAGSplusJSON_INVALID_UTF8_SUBSTITUTE), so a message with stray bytes cannot break the error response. Should rendering fail anyway (anHttpExceptionwith a header value PSR-7 refuses, for instance), the failure is logged ascriticaland a bare{"status":500}problem is sent.
Validation¶
Keel\Validation\* attributes on DTO constructor parameters are checked by the hydrator after
type casting; their messages join the same errors list:
final readonly class SignUp
{
public function __construct(
#[NotBlank, Length(min: 2, max: 50)] public string $name,
#[Email] public string $email,
#[Range(min: 18)] public int $age,
#[Pattern('/^[a-z0-9-]+$/', 'must be a slug')] public string $slug = '',
#[Choice(['free', 'pro'])] public string $plan = 'free',
#[Count(max: 5)] public array $tags = [],
#[Url(schemes: ['https'])] public ?string $site = null,
) {}
}
Rules: NotBlank, Length, Range, Count, Pattern, Alnum(allow: '-_'), Email, Url,
Uuid, Ip(version: 4|6), Hostname, Date(format: 'Y-m-d'), Json, Timezone, Choice,
Unique (no duplicate items) and Each(new NotBlank(), new Length(max: 30)), which runs its
rules on every item of an array and reports the first failing one as tags: [2]: must not be
blank. A rule runs
only on fields that were sent; null is checked by NotBlank alone; after a type error the
field's rules are skipped. A custom rule is an attribute class implementing
Keel\Validation\Rule::check().
Built-in middleware¶
Listed in HttpConfig; their settings are plain config objects in config/:
| Middleware | Config | What it does |
|---|---|---|
CorsMiddleware |
CorsConfig |
answers preflights (204) before routing, adds Access-Control-*; origins exact, * or patterns like https://*.example.com; allowCredentials requires an explicit origin list (* is refused in the constructor) |
RequestIdMiddleware |
— | trusts a well-formed X-Request-Id or issues one; request_id attribute and response header |
TrustedProxiesMiddleware |
TrustedProxiesConfig |
applies X-Forwarded-For/Proto/Host/Port only from trusted CIDRs; client IP in the client_ip attribute |
HttpCacheMiddleware |
HttpCacheConfig |
Cache-Control and ETag for 200 responses to GET/HEAD, 304 on If-None-Match / If-Modified-Since; see below |
CsrfMiddleware |
CsrfConfig |
synchronizer token on top of the session, see Sessions |
SecurityHeadersMiddleware |
SecurityHeadersConfig |
HSTS (HTTPS only), CSP, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy, COOP and CORP; see below |
TrustedHostsMiddleware |
TrustedHostsConfig |
400 for a host outside the allowlist; see below |
BodyLimitMiddleware |
BodyLimitConfig |
413 above the size limit, 415 for a media type not listed; #[BodyLimit] per route |
ServerTimingMiddleware |
ServerTimingConfig |
Server-Timing with the total and what handlers measured, optionally X-Response-Time |
IdempotencyMiddleware |
IdempotencyConfig |
stored responses for a repeated Idempotency-Key; #[Idempotent] per route |
LoadSheddingMiddleware |
LoadSheddingConfig |
503 with Retry-After under overload, see Runtimes |
MethodOverrideMiddleware |
— | POST + X-HTTP-Method-Override → PUT/PATCH/DELETE |
AuthMiddleware |
AuthConfig |
authenticates requests carrying credentials, see Authentication |
HealthMiddleware |
HealthConfig |
/health and /health/live, see Health and metrics |
MetricsMiddleware |
MetricsConfig |
Prometheus metrics and /metrics |
OpenApiMiddleware |
OpenApiConfig |
/openapi.json and /docs, see OpenAPI |
Route attributes add their own middleware: #[Deprecated] adds DeprecationMiddleware (below),
#[OutputCache] adds OutputCacheMiddleware (see Cache).
TrustedProxiesMiddleware takes the rightmost X-Forwarded-For address that is not itself a
trusted proxy. When every hop is trusted, as with proxies: ['*'], it takes the rightmost valid
address, the one appended by the proxy next to us, never the leftmost entry, which is whatever the
client sent. A trusted X-Forwarded-Proto that changes the scheme also drops the port of the
listening socket unless X-Forwarded-Port or a port in X-Forwarded-Host says otherwise.
The order of the list matters. One that works for an API behind a proxy:
return new HttpConfig([
TrustedProxiesMiddleware::class, // scheme, host and client IP as the client saw them
HealthMiddleware::class, // probes: any Host, never shed
MetricsMiddleware::class,
LoadSheddingMiddleware::class,
TrustedHostsMiddleware::class,
ServerTimingMiddleware::class, // measures everything below
OpenApiMiddleware::class, // before the strict CSP, its HTML page loads a script
SecurityHeadersMiddleware::class,
CorsMiddleware::class,
BodyLimitMiddleware::class,
RequestIdMiddleware::class,
AuthMiddleware::class,
IdempotencyMiddleware::class, // after auth: keys are per principal
]);
HTTP caching¶
HttpCacheMiddleware acts on 200 responses to GET and HEAD. It adds the configured
Cache-Control when the handler set none, an ETag hashed from the body when the handler set
none (etag: false turns that off), and answers a request whose If-None-Match matches the
ETag (weak comparison, lists and * understood) or whose If-Modified-Since is not older than
the response's Last-Modified with an empty 304 that keeps the validators and caching headers.
The handler still runs: what is saved is the transfer, not the work.
// config/http_cache.php: defaults for the global middleware
return new HttpCacheConfig(etag: true);
// per route or per controller
#[Get('/catalog')]
#[CacheControl(maxAge: 300, public: true)] // public, max-age=300 + ETag
public function catalog(): array { /* ... */ }
#[Get('/me')]
#[CacheControl(noStore: true)] // no-store, no ETag
public function me(Principal $user): array { /* ... */ }
#[CacheControl(maxAge, sharedMaxAge, public, mustRevalidate, immutable, noStore, etag)] adds
the middleware to the route with its own settings, so it works without the global middleware
and overrides HttpCacheConfig when both are present. Global middleware runs before routing
and therefore sees only the config; the attribute is what makes settings per route. Hashing
reads the whole body, so leave etag off for large downloads and streams (a non-seekable body
is skipped anyway).
Security headers¶
// config/security_headers.php
return SecurityHeadersConfig::api(); // JSON only: CSP "default-src 'none'; frame-ancestors 'none'"
return new SecurityHeadersConfig(
contentSecurityPolicy: "default-src 'self'; img-src 'self' data:",
permissionsPolicy: ['camera' => [], 'geolocation' => ['self']], // camera=(), geolocation=(self)
);
The defaults: Strict-Transport-Security: max-age=31536000; includeSubDomains on HTTPS requests
only (browsers ignore it over plain HTTP; behind a TLS-terminating proxy the scheme comes from
TrustedProxiesMiddleware), X-Frame-Options: DENY, X-Content-Type-Options: nosniff,
Referrer-Policy: strict-origin-when-cross-origin and Cross-Origin-Opener-Policy: same-origin.
CSP (optionally report-only), Permissions-Policy and Cross-Origin-Resource-Policy stay off
until configured; set hstsPreload only after submitting the domain to the preload list. A
header the response already has is kept, so one page (an HTML report, the API reference) can
send its own policy. X-Powered-By comes from PHP itself: set expose_php = Off.
Trusted hosts¶
// config/trusted_hosts.php
return new TrustedHostsConfig(['api.example.com', '*.example.com']);
A request for any other host gets 400 before routing, so a forged Host never ends up in
generated URLs, redirects or password-reset links. *.example.com covers subdomains at any depth
but not example.com itself; IP literals (10.0.0.5, [::1]) and * are allowed. The host is
read from the request URI, after TrustedProxiesMiddleware applied a trusted X-Forwarded-Host.
Probes that address a pod by its IP need that IP in the list, or HealthMiddleware earlier in
HttpConfig.
Request body limits¶
// config/body_limit.php: every request with a body
return new BodyLimitConfig(maxBytes: '1M', contentTypes: ['application/json', 'application/*+json']);
#[Post('/avatars')]
#[BodyLimit('20M', contentTypes: ['multipart/form-data', 'image/*'])]
public function upload(#[FromFile] UploadedFileInterface $avatar): array { /* ... */ }
A Content-Length above the limit is answered 413 before anything is read; a body without one
(chunked) is wrapped so that reading past the limit throws 413 instead of filling the worker's
memory. A body whose media type matches none of the patterns (exact, type/* or
type/*+suffix) gets 415. Requests without a body pass untouched. The global middleware reads
the route's #[BodyLimit] with a cheap match before routing, so a route can raise the limit
above the global one; a null argument keeps the global value. The attribute works without the
global middleware too, and the OpenAPI document lists 413 and 415 for the route. The server's
own limits apply first: post_max_size under php-fpm, http.max_request_size in RoadRunner,
request_body in Caddy.
Idempotency keys¶
// config/idempotency.php; needs a PSR-16 cache (CacheProvider)
return new IdempotencyConfig(ttl: 86_400);
#[Post('/payments')]
#[Idempotent(required: true)]
public function pay(#[FromBody] Payment $payment): array { /* ... */ }
The client sends Idempotency-Key: <uuid> with an unsafe request (draft-ietf-httpapi-idempotency-key-header).
The first request runs and its response (any status below 500 except 429) is stored for ttl
seconds. A retry with the same key and the same method, path, query and body gets that response,
marked Idempotent-Replayed: true, without running the handler; the same key with a different
request gets 422; a retry while the first is still running gets 409 with Retry-After. A request
that failed (5xx or an exception) releases its key, so the retry runs. Keys are 1-255 visible
ASCII characters (a quoted structured-field string is accepted) and are scoped to the principal,
or else to the Authorization header: place the middleware after AuthMiddleware.
The lock is an atomic CounterStorage increment: APCu, Redis (RedisConfig(use: ['counters']))
or, without either, process memory, which only protects one worker. As global middleware it
covers POST and PATCH (IdempotencyConfig::$methods) that carry a key, merged with the route's
#[Idempotent]; the attribute alone applies to the route whatever its method and documents the
header, 409 and 422 in OpenAPI. Streamed responses are not stored.
Server-Timing¶
// config/server_timing.php
return new ServerTimingConfig(responseTimeHeader: 'X-Response-Time');
#[Get('/users/{id}')]
public function show(int $id, ServerTiming $timing): UserView
{
$user = $timing->measure('db', fn() => $this->users->find($id), 'User lookup');
$timing->add('cache', 0.4);
return UserView::from($user);
}
ServerTimingMiddleware measures everything below it and answers
Server-Timing: db;dur=3.2;desc="User lookup", cache;dur=0.4, total;dur=7.9, which browsers show in
the network panel. ServerTiming is a fresh collector per request, stored as a request attribute
and injected by type (ServerTiming::of($request) in a middleware); start()/stop(),
measure() and add() record milliseconds. timingAllowOrigin lets pages of other origins read
the numbers, metrics: false sends the total alone. Timings tell anyone how long your backends
take: think twice before exposing them on a public API.
Deprecating routes¶
#[Get('/v1/users')]
#[Deprecated(since: '2026-07-01', sunset: '2027-01-01', link: 'https://docs.example.com/v2', successor: 'https://api.example.com/v2/users')]
public function list(): array { /* ... */ }
Responses of the route carry Deprecation: @1782864000 (RFC 9745), Sunset: Fri, 01 Jan 2027
00:00:00 GMT (RFC 8594), Link: <...>; rel="deprecation" and Link: <...>; rel="successor-version";
without since the route is announced as deprecated already (@0). The OpenAPI operation gets
deprecated: true and x-sunset, and keel:routes marks the handler. Dates are read as UTC; a
sunset earlier than since or a URL that cannot sit in a Link header fails when routes are
compiled. Errors thrown by the handler are answered without the headers. Not to be confused with
PHP's own #[\Deprecated], which makes calling the method emit E_USER_DEPRECATED.
Miscellaneous¶
MethodOverrideMiddlewareis opt-in; it overrides the method only forPOSTand only toPUT/PATCH/DELETEbased on theX-HTTP-Method-Overrideheader.SapiEmittersends a response through the SAPI (php-fpm, built-in server); the front controller example isexamples/hello/public/index.php. A non-seekable body is streamed, see above.SapiEmitter::finish()ends the response for the client while the script goes on (fastcgi_finish_request(),frankenphp_finish_request());FpmRuntimecalls it beforeKernel::terminate().