# Middleware Kinetis's middleware is plain [PSR-15](https://www.php-fig.org/psr/psr-15/) — `Psr\Http\Server\MiddlewareInterface` and `RequestHandlerInterface` — not an Kinetis-specific contract. Any existing PSR-15 middleware package works against Kinetis unmodified, and middleware you write yourself isn't learning a framework-specific shape. ```{code-block} php use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; final readonly class RequestTimingMiddleware implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $start = microtime(true); $response = $handler->handle($request); $elapsedMs = (microtime(true) - $start) * 1000; return $response->withHeader('X-Response-Time', sprintf('%.2fms', $elapsedMs)); } } ``` `process()` decides whether to call `$handler->handle($request)` at all — call it and you're "before and after" middleware (like the timing example above); return your own response without calling it and you've short-circuited the pipeline before anything further down ever runs. ## Two pipelines, not one Middleware register in two different places, for two different reasons. (global-middleware-every-request-including-ones-that-never-match-a-route)= ### Global middleware — every request, including ones that never match a route ```{code-block} php use Kinetis\Container\AppScope; $app = new AppScope(); $app->middleware(RequestTimingMiddleware::class); $app->middleware(CorsMiddleware::class); $app->boot(); ``` Registered on `AppScope` (locked after `boot()`, the same discipline as `bind()`/`instance()` — see {doc}`container`), in registration order, outermost first. This wraps `Kernel::handle()`'s *entire* body — the OpenAPI/MCP short-circuits, routing itself, and a `404`/`405` from a failed route match — not just a successfully dispatched request. That's why logging or CORS belongs here: you want it to see every request, not only the ones that happened to match something. Global middleware is resolved from `AppScope`, not a per-request scope — it has to wrap the request *before* any `RequestScope` exists (the OpenAPI/MCP branches deliberately never create one at all; see {doc}`core-concepts`), so it can't depend on one at construction time. Practically, this makes a global middleware instance a worker-lifetime singleton by default — the same "singleton via the container" pattern {doc}`container` documents for a plain service. If your middleware holds no per-request state as an instance property, that's exactly as safe as any other `AppScope`-resolved service; if it needs something that varies per request, reach for route middleware instead. ### Discoverable global middleware — no `AppScope::middleware()` call needed `#[AsGlobalMiddleware]` registers a global middleware class by attribute instead — the opposite direction from `#[Middleware]` above, which lives on a *controller* referencing another class; this one lives on the middleware class itself: ```{code-block} php use Kinetis\Http\Attributes\AsGlobalMiddleware; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; #[AsGlobalMiddleware] final readonly class RequestIdMiddleware implements MiddlewareInterface { public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { return $handler->handle($request)->withHeader('X-Request-Id', bin2hex(random_bytes(8))); } } ``` Any class anywhere under one of your own PSR-4 roots carrying this attribute joins the global pipeline automatically, with no `$app->middleware(...)` call at all. It runs *inward* of every explicitly registered middleware, as a group — explicit registration always wins. **Ordering among multiple discovered classes** is `priority`, an integer from `0` to `100` defaulting to `50` — higher runs more outer (closer to `ExceptionHandlerMiddleware`, further from the controller). The default sits at the midpoint specifically so a class can be nudged either more outer or more inner than every unspecified default without needing to know the range's extremes; a value outside `0`-`100` throws `InvalidArgumentException` immediately, when the attribute is constructed: ```{code-block} php #[AsGlobalMiddleware(priority: 90)] final readonly class RequestIdMiddleware implements MiddlewareInterface { /* ... */ } #[AsGlobalMiddleware(priority: 10)] final readonly class ResponseTimingMiddleware implements MiddlewareInterface { /* ... */ } ``` Two classes sharing a priority are ordered alphabetically by their own fully-qualified class name instead, so the result never depends on filesystem/scan order. ```{note} This priority/alphabetical-tiebreak scheme is specific to *discovered* global middleware — it exists because nothing else establishes a relative order between two independently-discovered classes. `#[Middleware]` (class/method-level route middleware, above) has no priority concept at all: multiple `#[Middleware(...)]` attributes always run in the exact order they're declared in your source, since a controller's own attribute order is already an explicit, unambiguous ordering with nothing left to break a tie on. ``` ```{note} Kinetis's own built-in middleware (`CorsMiddleware`, `RateLimitMiddleware`, `AuthenticatedRateLimitMiddleware`) is never `#[AsGlobalMiddleware]`-attributed — each needs app-specific constructor config (allowed origins, a limit) no default could supply, so they stay opt-in via `$app->middleware(...)` only, exactly as described below. This attribute is for *your* middleware. ``` Restrict the scan for a large application the same way as {doc}`cli`'s route/command/tool discovery: `MIDDLEWARE_DISCOVERY_PATHS`, comma-separated sub-paths relative to each PSR-4 base directory, committed in `.env`. See {doc}`caching` for how this is compiled ahead of time in production, alongside the route table itself. ### Route middleware — attribute-driven, per endpoint ```{code-block} php use Kinetis\Http\Attributes\Get; use Kinetis\Http\Attributes\Middleware; use Kinetis\Http\Middleware\RateLimitMiddleware; #[Middleware(AuthMiddleware::class)] final readonly class OrderController { #[Get('/orders')] #[Middleware(RateLimitMiddleware::class)] public function index(): array { /* ... */ } } ``` `#[Middleware(SomeMiddleware::class)]` is repeatable and works at both levels: class-level applies to every route on the controller and runs outermost; method-level appends, closer to the controller. Stack as many as you need at either level — in the example above, a request to `GET /orders` runs `AuthMiddleware` first, then `RateLimitMiddleware`, then the controller. `Router::register()` discovers these the same way it discovers `#[Get]`/`#[Post]`/etc. — one more `getAttributes()` call inside the reflection loop it already runs, not a second pass over your controllers. Unlike global middleware, route middleware is resolved from the request's own `RequestScope`, wrapping only `Dispatcher::dispatch()` — deliberately the opposite resolution source from global middleware, since this is exactly the kind likely to need a per-request dependency. ## Registering a value the controller reads later `RequestScope` registers itself on itself, so a middleware can constructor-inject the exact scope the current request is using — not a disconnected new one — and write something onto it for a controller to read afterward: ```{code-block} php use Kinetis\Container\RequestScope; use Kinetis\Http\CurrentUserInterface; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; final readonly class AuthMiddleware implements MiddlewareInterface { public function __construct( private RequestScope $scope, private CurrentUserResolver $users, ) {} public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $user = $this->users->resolve($request); if ($user === null) { return new \Nyholm\Psr7\Response(401, ['Content-Type' => 'application/json'], json_encode(['error' => 'Unauthenticated.'])); } $this->scope->instance(CurrentUserInterface::class, $user); return $handler->handle($request); } } ``` ```{code-block} php use Kinetis\Http\Attributes\Get; use Kinetis\Http\CurrentUserInterface; final readonly class OrderController { public function __construct( private CurrentUserInterface $user, ) {} #[Get('/orders')] public function index(): array { return ['userId' => $this->user->id()]; } } ``` `CurrentUserInterface` (`Kinetis\Http\CurrentUserInterface`) is one method — `id(): string|int` — deliberately minimal so any auth strategy can implement it. Nothing implements or registers it by default: a controller constructor-injecting it without an auth middleware having run first gets a plain `NotFoundException`, not a null to check. ## Built in: `ExceptionHandlerMiddleware` Registered as the outermost global middleware automatically, on every `Kernel` — not something you opt into: ```{code-block} text Kernel's global pipeline, outermost to innermost: ExceptionHandlerMiddleware ← always first, unconditionally MaxBodySizeMiddleware ← always second, unconditionally ...your own $app->middleware() registrations, in order... (routing, then a matched route's own middleware, then the controller) ``` Without it, an uncaught exception from anywhere in the pipeline — a controller, a route middleware, application code in general — would propagate all the way out of `Kernel::handle()` with nothing converting it into a response. For a persistent worker, that's a materially worse failure mode than one request degrading to a `500`, which is why it's always on rather than something you opt into. ```{code-block} json :caption: What a controller throwing an uncaught exception produces { "error": "Internal server error." } ``` ```{note} This also logs the exception through whatever `Psr\Log\LoggerInterface` you've registered — see {doc}`logging`. ``` Middleware registration is a flat class-string list at both levels — a middleware needing a threshold or a config value takes it through the container via constructor injection, like anything else. ## Built in: `MaxBodySizeMiddleware` Registered unconditionally, right after `ExceptionHandlerMiddleware` — also not something you opt into. Without it, nothing checks how large a request body is before `#[Body]` reads the whole thing into memory and `json_decode()`s it. ```{code-block} text :caption: .env MAX_BODY_SIZE=2097152 ``` Bytes, not a `"2M"`-style string. Defaults to `2097152` (2 MiB) when unset. ```{code-block} json :caption: What an oversized request produces (413) { "error": "Request body of 5000000 bytes exceeds the maximum allowed size of 2097152 bytes." } ``` Only the declared `Content-Length` header is checked, not the actual bytes read as they arrive — a request with no `Content-Length`, or one that under-reports its real size, passes through this check untouched. ## Built in: `CorsMiddleware` `Kinetis\Http\Middleware\CorsMiddleware` — Cross-Origin Resource Sharing. **Global only** — it's the one built-in middleware that can't be used as route middleware at all: ```{code-block} php use Kinetis\Http\Middleware\CorsMiddleware; $app->middleware(CorsMiddleware::class); ``` A CORS preflight (`OPTIONS` with `Access-Control-Request-Method`) to a path with no registered `OPTIONS` route would never reach route middleware at all, since that only runs after a route has already matched successfully. Registering `CorsMiddleware` globally is what lets it see and answer the preflight before routing even runs. ```{code-block} php new CorsMiddleware( allowedOrigins: ['https://app.example.com'], allowedMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], exposedHeaders: [], allowCredentials: false, maxAge: 86400, ); ``` Defaults to `allowedOrigins: ['*']`. A request with no `Origin` header, or an `Origin` not on the allow list, passes through completely untouched — no CORS headers added, no error status returned. That's deliberate: it's the browser's own same-origin policy that blocks a disallowed cross-origin response once it doesn't see an `Access-Control-Allow-Origin` header naming it; nothing server-side needs to reject the request itself. `allowedHeaders: ['*']` reflects whatever the preflight actually requested (`Access-Control-Request-Headers`) instead of checking against a fixed list — maintaining an exhaustive static allow-list is brittle against a client sending one custom header more than expected. ```{warning} **Wildcard origins and credentials never combine, per spec.** Browsers reject `Access-Control-Allow-Origin: *` outright when credentials are involved. `allowCredentials: true` always echoes back the specific requesting origin instead, even when `allowedOrigins: ['*']` is configured. Echoing a specific origin also adds `Vary: Origin`, since the response then varies by request origin; a static `*` response doesn't need it, since it's identical regardless of origin. ``` ### Matching a pattern of origins, not just a fixed list `allowedOriginPatterns` checks the `Origin` header against full, delimited PCRE patterns when it matches none of `allowedOrigins` exactly — for "any subdomain of `example.com`", not expressible as a fixed list: ```{code-block} php new CorsMiddleware( allowedOrigins: [], allowedOriginPatterns: ['#^https://[a-z0-9-]+\.example\.com$#'], ); ``` ```{danger} **Every pattern must be anchored (`^`...`$`) with every literal `.` escaped (`\.`) — an unanchored or unescaped pattern is a recurring class of CORS-misconfiguration vulnerability.** An unanchored, unescaped `example.com` treats the `.` as "any character" and has no start/end boundary, so it matches `https://evil-example.com.attacker.net` exactly as happily as the intended subdomain. Patterns aren't validated at construction time; a malformed or under-anchored one is a configuration bug the same way any other misconfigured constructor argument would be. ``` For anything beyond pattern matching against the `Origin` header itself — a per-tenant allow-list, for example — write your own middleware using `CorsMiddleware` as a starting point. ## Built in: `RateLimitMiddleware` `Kinetis\Http\Middleware\RateLimitMiddleware` — a fixed-window request counter backed by `Psr\SimpleCache\CacheInterface` (see {doc}`persistence` for `RedisSimpleCache`/`NullSimpleCache`, the two implementations `AppScope::boot()` chooses between automatically). Not registered by default — opt in as global or route middleware, whichever fits: ```{code-block} php use Kinetis\Http\Middleware\RateLimitMiddleware; $app->middleware(RateLimitMiddleware::class); // every request ``` ```{code-block} php use Kinetis\Http\Attributes\Get; use Kinetis\Http\Attributes\Middleware; use Kinetis\Http\Middleware\RateLimitMiddleware; final readonly class LoginController { #[Get('/login')] #[Middleware(RateLimitMiddleware::class)] // just this route public function attempt(): array { /* ... */ } } ``` Either way, `CacheInterface` autowires from whatever `AppScope::boot()` registered — no extra wiring needed. It's safe as global middleware specifically because it holds no per-request state as instance properties, the same criterion [above](#global-middleware-every-request-including-ones-that-never-match-a-route) already establishes for any global middleware. Defaults to 60 attempts per 60-second window, keyed by client IP (`REMOTE_ADDR`), sha1-hashed before use — not for concealment, but because PSR-16 forbids `{}()/\@:` in a key, and a bare IPv6 address is full of colons. A request past the limit gets: ```{code-block} json :caption: 429, once the limit is reached { "error": "Too many requests." } ``` with `Retry-After` (seconds until the current window resets) and `X-RateLimit-Limit`/`X-RateLimit-Remaining` headers — the latter two are also set on every successful response, not just the rejection, so a client can see its remaining quota before actually hitting it. ### Behind a reverse proxy or load balancer `REMOTE_ADDR` is the address of whatever connected directly — behind a real reverse proxy or load balancer, that's the proxy's own address on every request, not the real client's, so every distinct client collapses into one shared bucket. `trustedProxies` opts into reading `X-Forwarded-For` instead, but only for a request that actually came through one of the given CIDR ranges — never unconditionally, since a client can set that header to anything it likes: ```{code-block} php new RateLimitMiddleware($cache, trustedProxies: ['10.0.0.0/8']); ``` ```{code-block} text :caption: .env TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12 ``` Read this yourself in your own bootstrap code and pass it through — the middleware doesn't read `Config` itself, the same convention `allowedOrigins` on `CorsMiddleware` already follows: ```{code-block} php $app->bind(RateLimitMiddleware::class, function ($c) { $trustedProxies = $c->get(Config::class)->string('TRUSTED_PROXIES', ''); return new RateLimitMiddleware( $c->get(CacheInterface::class), trustedProxies: $trustedProxies === '' ? [] : explode(',', $trustedProxies), ); }); ``` When a request comes through more than one trusted hop, the `X-Forwarded-For` chain is walked from the end backward, skipping every entry that's itself a trusted proxy — the first untrusted entry is the real client. ### A different limit for a different route `#[Middleware(...)]` only ever carries a class-string, no arguments (see above) — a login endpoint wanting 5/minute while the rest of the API gets 60/minute is a thin subclass fixing its own constructor defaults: ```{code-block} php use Kinetis\Http\Middleware\RateLimitMiddleware; use Psr\SimpleCache\CacheInterface; final class LoginRateLimitMiddleware extends RateLimitMiddleware { public function __construct(CacheInterface $cache) { parent::__construct($cache, maxAttempts: 5, windowSeconds: 60); } } ``` Overriding the global default instead — every route, one new limit — is a single `AppScope::bind()` closure rather than a subclass: ```{code-block} php $app->bind(RateLimitMiddleware::class, fn ($c) => new RateLimitMiddleware( $c->get(Psr\SimpleCache\CacheInterface::class), maxAttempts: 100, windowSeconds: 60, )); ``` ```{note} The `get()`-then-`set()` pair behind this isn't atomic — under concurrent requests hitting the same window, two requests can both read the same count and both write the same incremented value, silently losing an increment. This is a real, accepted limitation of building against the plain PSR-16 interface rather than a backend-specific atomic `INCR`, which would make this Redis-only and defeat the point of depending on `CacheInterface` rather than `Amp\Redis\RedisClient` directly. For most rate-limiting use cases this is a rounding error, not a correctness bug — an exact limiter needs a backend offering an atomic increment primitive, which PSR-16 doesn't expose. ``` ### Keying by the authenticated user instead of IP `Kinetis\Http\Middleware\AuthenticatedRateLimitMiddleware` extends `RateLimitMiddleware`: it keys by `CurrentUserInterface::id()` when one has already been resolved onto the current request (see ["Registering a value the controller reads later"](#registering-a-value-the-controller-reads-later) above), falling back to the same IP-based identifier otherwise. ```{code-block} php use Kinetis\Http\Attributes\Get; use Kinetis\Http\Attributes\Middleware; use Kinetis\Http\Middleware\AuthenticatedRateLimitMiddleware; final readonly class OrderController { #[Get('/orders')] #[Middleware(AuthMiddleware::class)] // resolves CurrentUserInterface first #[Middleware(AuthenticatedRateLimitMiddleware::class)] // then keys by it public function index(): array { /* ... */ } } ``` Ordering matters — the middleware that resolves `CurrentUserInterface` must run first, so it's already registered on the scope by the time this one reads it. ```{warning} Route middleware only — never register this globally, and never bind `AuthenticatedRateLimitMiddleware::class` directly on `AppScope` with a factory that also resolves `RequestScope`. `AppScope::resolve()` falls back to autowiring any real class it has no explicit binding for (unlike `AppScope::has()`, which is explicit-only), so a factory calling `$c->get(RequestScope::class)` where `$c` is `AppScope` would silently construct a brand-new, disconnected `RequestScope` instead of reaching the real per-request one. It's always safe as route middleware, resolved fresh per request the normal way — no binding needed at all, the same as any other constructor with only class-typed parameters. ``` Also not atomic, for the same reason as the base class — and also deliberately not `final`, so a stricter per-route limit still works via the same subclass pattern. ## See also - {doc}`container` — `AppScope`/`RequestScope`, and the "singleton via the container" pattern global middleware relies on. - {doc}`logging` — registering your own logger, and the other two places Kinetis logs on its own. - {doc}`core-concepts` — the request lifecycle both pipelines sit inside. - {doc}`auth` — a ready-made bearer-token implementation of the `AuthMiddleware` pattern shown above. - {doc}`caching` — how route middleware, and `#[AsGlobalMiddleware]`-discovered classes, are stored in the production cache. - {doc}`cli` — restricting namespace-based discovery for a large application, the same mechanism `MIDDLEWARE_DISCOVERY_PATHS` follows.