Middleware

Kinetis’s middleware is plain PSR-15Psr\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.

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

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

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:

#[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 CLI’s route/command/tool discovery: MIDDLEWARE_DISCOVERY_PATHS, comma-separated sub-paths relative to each PSR-4 base directory, committed in .env. See Caching & AOT Compilation for how this is compiled ahead of time in production, alongside the route table itself.

Route middleware — attribute-driven, per endpoint

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:

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

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.

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 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.

.env
MAX_BODY_SIZE=2097152

Bytes, not a "2M"-style string. Defaults to 2097152 (2 MiB) when unset.

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:

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.

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:

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

use Kinetis\Http\Middleware\RateLimitMiddleware;

$app->middleware(RateLimitMiddleware::class); // every request
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 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:

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:

new RateLimitMiddleware($cache, trustedProxies: ['10.0.0.0/8']);
.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:

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

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:

$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” above), falling back to the same IP-based identifier otherwise.

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

  • ContainerAppScope/RequestScope, and the “singleton via the container” pattern global middleware relies on.

  • Logging — registering your own logger, and the other two places Kinetis logs on its own.

  • Core Concepts — the request lifecycle both pipelines sit inside.

  • Authentication — a ready-made bearer-token implementation of the AuthMiddleware pattern shown above.

  • Caching & AOT Compilation — how route middleware, and #[AsGlobalMiddleware]-discovered classes, are stored in the production cache.

  • CLI — restricting namespace-based discovery for a large application, the same mechanism MIDDLEWARE_DISCOVERY_PATHS follows.