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 — and so does a class an installed package offers through its extra.kinetis scan roots (see CLI). 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 belongs to discovered middleware — this attribute, and #[AsMiddlewareGroup] below for ordering within a group. 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 — group references included — 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.

Scoping middleware to /openapi.json//openapi or /mcp specifically

Global middleware already wraps every route, these endpoints included. The narrower need — middleware that should run for only one of them — is served by two mechanisms with one underlying shape, since both endpoints are ordinary discovered routes on controllers a package or the framework ships:

#[AsOpenApiMiddleware] covers /openapi.json and /openapi together — the same “expose the API’s own shape” concern, not two independently protectable surfaces. Its classes are published as a built-in openapi middleware group that the framework’s DocumentationController references like any other route middleware. It takes the same priority (bounded 0-100, default 50, alphabetical tiebreak) as #[AsGlobalMiddleware], is discovered by the same project-wide scan, and has an explicit-registration counterpart, AppScope::openApiMiddleware(SomeClass::class).

The mcp middleware group covers /mcp, which kinetis/mcp’s own controller references via #[Middleware('@mcp')]. Join it by declaring membership:

use Kinetis\Http\Attributes\AsMiddlewareGroup;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

#[AsMiddlewareGroup('mcp')]
final readonly class McpAuthMiddleware implements MiddlewareInterface
{
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        if ($request->getHeaderLine('Authorization') !== 'Bearer ' . getenv('MCP_TOKEN')) {
            return new \Nyholm\Psr7\Response(401, ['Content-Type' => 'application/json'], json_encode(['error' => 'Unauthenticated.']));
        }

        return $handler->handle($request);
    }
}

Because a group is route middleware — resolved from each request’s own scope — a class here can constructor-inject RequestScope and publish CurrentUserInterface for the tool to see, which is how the auth packages work on /mcp unchanged. The package’s own McpOriginMiddleware is a permanent group member at priority 100, so the spec-required Origin validation always runs first; see Model Context Protocol (MCP)’s “Securing the HTTP transport”.

Note

Order matters: route middleware runs inside the global pipeline, not instead of it. For a request to /mcp, global middleware runs first (outermost), then the mcp group, then the MCP request 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.

Naming a stack: middleware groups

When several routes need the same few middleware in the same order, #[AsMiddlewareGroup] names that stack once, on the middleware classes themselves:

use Kinetis\Http\Attributes\AsMiddlewareGroup;

#[AsMiddlewareGroup('auth')]
#[AsMiddlewareGroup('admin', priority: 90)]
final class AuthMiddleware implements MiddlewareInterface { /* ... */ }
#[AsMiddlewareGroup('admin', priority: 50)]
final class RequireAdminMiddleware implements MiddlewareInterface { /* ... */ }

A route or controller then references the whole group with a @-prefixed name instead of listing every class:

final readonly class OrderController
{
    #[Get('/orders')]
    #[Middleware('@auth')]
    public function index(): array { /* ... */ }

    #[Get('/orders/{id}/refund')]
    #[Middleware('@admin')]
    public function refund(int $id): array { /* ... */ }
}

GET /orders/{id}/refund runs AuthMiddleware then RequireAdminMiddleware — the admin group’s own order, from the priorities declared above: higher runs more outer, 0-100, defaulting to 50, with members sharing a priority ordered alphabetically by class name. The attribute is repeatable, so one class can belong to several groups and hold a different position in each.

Nothing needs registering. Any class anywhere under one of your own PSR-4 roots carrying #[AsMiddlewareGroup] is found automatically, the same scan that finds #[AsGlobalMiddleware] classes (see above, including how to restrict it on a large application).

A group expands where its reference sits, so declaration order still governs the whole list — mix group references and plain class-strings freely:

#[Get('/orders/export')]
#[Middleware(RateLimitMiddleware::class)]
#[Middleware('@admin')]
public function export(): array { /* ... */ }

That runs RateLimitMiddleware, then the admin group’s two members, then the controller.

Group membership alone never makes a middleware run anywhere — a group only runs where a route or controller references it. Referencing a group no class declares fails when the application starts, naming the group and the route that referenced it, rather than at the moment someone hits that endpoint.

kinetis routes:list prints each route’s group references already expanded into the classes that actually run, annotated with the group they came from — see CLI.

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.

The controller above can constructor-inject what the middleware registered because a controller is resolved after every middleware in front of it has run. A middleware cannot do the same for something an earlier middleware registers: all of a route’s middleware are constructed before the first one runs, so at construction time none of them has executed yet. A middleware that depends on an earlier one’s work resolves it inside process() instead, from an injected RequestScope:

final readonly class RequireVerifiedEmailMiddleware implements MiddlewareInterface
{
    // Injecting CurrentUserInterface here would fail: the auth
    // middleware in front of this one has not run yet.
    public function __construct(private RequestScope $scope) {}

    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $user = $this->scope->get(CurrentUserInterface::class);

        // ...
    }
}

Built in: ExceptionHandlerMiddleware

Registered automatically on every Kernel, immediately inside SecurityHeadersMiddleware — not something you opt into:

Kernel's global pipeline, outermost to innermost:
  SecurityHeadersMiddleware    ← always first, unconditionally
  ExceptionHandlerMiddleware   ← always second, unconditionally
  MaxBodySizeMiddleware        ← always third, 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."
}

In development (APP_ENV=development), the same 500 carries the exception’s class, message, and location, so a mistake is diagnosable straight from the response:

The same failure, in development
{
    "error": "Internal server error.",
    "exception": "RuntimeException",
    "message": "boom",
    "location": "/app/src/Http/OrderController.php:24"
}

Note

Either way, the exception is also logged through whatever Psr\Log\LoggerInterface is bound — in development that’s an error_log()-backed logger by default, so the trail exists even where the response body isn’t visible. 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: SecurityHeadersMiddleware

Registered unconditionally as the outermost global middleware — outside ExceptionHandlerMiddleware, so its headers reach the 500 that handler produces as well as every ordinary response. It cannot throw at request time: configuration is read once at construction, so process() does nothing but set headers.

Three headers are sent by default, with no configuration at all:

X-Content-Type-Options: nosniff
X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin

Nothing legitimate depends on content sniffing, on being framed, or on leaking a full referrer to another origin, so these cost a working application nothing and protect one that never thought about them. X-Frame-Options and Referrer-Policy take any value, or off to send nothing; X-Content-Type-Options is not configurable, because there is no reason to turn sniffing back on.

.env
SECURITY_FRAME_OPTIONS=SAMEORIGIN
SECURITY_REFERRER_POLICY=no-referrer

A Content-Security-Policy, a Permissions-Policy, HSTS, and the three cross-origin policies are sent only when you configure them. Each breaks a working application when it is wrong — a policy that omits a real dependency blocks it, and HSTS on the wrong host is not quickly reversible — so a guessed default would do more harm than sending nothing:

.env
SECURITY_CSP=default-src 'self'; object-src 'none'; frame-ancestors 'none'
SECURITY_PERMISSIONS_POLICY=geolocation=(), microphone=(), camera=()
SECURITY_HSTS_MAX_AGE=31536000
SECURITY_HSTS_INCLUDE_SUBDOMAINS=true
SECURITY_HSTS_PRELOAD=false
SECURITY_COOP=same-origin-allow-popups
SECURITY_CORP=same-origin
SECURITY_COEP=require-corp

HSTS is sent whenever a max-age is configured, without checking the request’s own scheme: a browser is required to ignore it when it did not arrive over a secure transport, and a scheme check would suppress it behind a proxy that terminates TLS — where it is exactly what you want.

The three cross-origin policies each sever something the web allows by default, which is the point of them and the reason to reach for one deliberately:

SECURITY_COOP cuts the window.opener link between your pages and the windows around them. same-origin-allow-popups keeps popups your own pages open — which is how an OAuth or payment popup reports back — while same-origin also severs the link when one of your pages is the popup, so choose it only if you are not the one being opened.

SECURITY_CORP set to same-origin stops other origins embedding your responses, including images and fonts they embed today. It does not apply to a CORS request, so an API consumed through CorsMiddleware is unaffected either way.

SECURITY_COEP set to require-corp demands that every cross-origin subresource opt in, and blocks each one that has not. It is what crossOriginIsolated needs, and the most disruptive of the three — introduce it last, after the other two are in place.

Note

Your policy does not have to accommodate the Swagger UI page Kinetis serves at /openapi. That page loads swagger-ui-dist from a CDN, which a script-src of 'self' would block, so it sends its own policy — narrower than a typical application-wide one, with a per-response nonce for its inline script and connect-src 'self' so it can fetch its own document and nothing else. Because a header already on the response is never replaced, yours still governs every other route.

A header the response already carries is never replaced, so one route can set its own policy and keep it:

#[Get('/embed/widget')]
public function widget(): ResponseInterface
{
    // Kept as-is; the global DENY does not overwrite it.
    return HtmlResponse::create($markup)
        ->withHeader('X-Frame-Options', 'SAMEORIGIN');
}

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 exceeds the maximum allowed size of 2097152 bytes."
}

Two checks, not one. A declared Content-Length over the limit is rejected immediately, before the body is touched at all. Underneath that, the body itself is capped as it’s actually read — so a request with no Content-Length header, or one that under-reports its real size, is still rejected once a #[Body] route actually reads past the limit. A route that never reads the body (a GET, or one using only #[Query]/path parameters) is unaffected either way, since nothing tries to read past the limit in the first place.

Only the raw JSON #[Body] path is capped this way — a multipart/form-data or application/x-www-form-urlencoded body is parsed before Kinetis code reads it, bounded by PHP’s own upload_max_filesize/post_max_size instead.

Note

That SAPI-limit explanation is specific to FrankenPHP/PHP-FPM, where a real SAPI parses the body before this middleware ever runs. Under kinetis/bref-adapter, a Lambda event’s whole body arrives as one in-memory string with no SAPI involved at all — BrefLambdaAdapter parses multipart/form-data/application/x-www-form-urlencoded itself, in userland PHP, so neither upload_max_filesize/post_max_size nor this middleware’s own read-time check apply there; the effective limit is whatever Lambda’s own payload size limit already is.

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: [] — deny by default. Nothing is cross-origin-accessible until you explicitly list allowed origins (or opt into allowedOrigins: ['*'] yourself, with allowCredentials: false). 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, so CorsMiddleware refuses to construct at all with allowedOrigins: ['*'] and allowCredentials: true together — that combination has no safe fallback to silently apply, it’s a misconfiguration to catch before it ships. Use a real allow-list (or allowedOriginPatterns) instead if you need credentialed cross-origin requests; with one configured, a credentialed response always echoes back the specific requesting origin rather than a static value, which also adds Vary: Origin, since the response then varies by request 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$#'],
);

A pattern has to match the Origin in full. A partial match is not enough, so an unanchored example\.com does not allow https://evil-example.com.attacker.net — the recurring class of CORS misconfiguration this parameter would otherwise invite. Anchors are still worth writing for clarity, but leaving them out cannot widen what a pattern allows.

The whole-Origin rule is what enforces this, rather than a check that the pattern carries ^ and $. Such a check cannot be trusted: an alternation like #^https://good\.com$|evil\.com$# carries both anchors and is still unanchored on its second branch, so it would pass inspection while allowing any origin ending in evil.com.

Danger

Escaping literal dots is still yours to get right. .+example\.com matches https://evilexample.com in full, and nothing generic can tell that from an intended pattern. Write \. for a literal dot.

Patterns are compiled when the middleware is constructed, and one that cannot compile raises InvalidArgumentException there — it would otherwise match nothing and quietly deny every origin it was written to allow.

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. It needs a real cache: configure Redis (REDIS_URL or REDIS_HOST — see Persistence) so AppScope::boot() binds RedisSimpleCache, or pass any other real PSR-16 implementation. Construction over NullSimpleCache — the default binding when no Redis is configured — throws, since a counter that never stores anything enforces no limit at all while still emitting healthy-looking X-RateLimit-* headers. 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), sha256-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),
        // Trimmed, so a space after a comma in .env is not read as
        // part of the next range.
        trustedProxies: $trustedProxies === '' ? [] : array_map(trim(...), 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.

Each range is parsed when the middleware is constructed. One that cannot be — a prefix length outside 0-32 for IPv4 or 0-128 for IPv6, or an address that isn’t one — raises Exception\InvalidRateLimitConfigException there rather than on the first request to reach it, since the list decides who is allowed to set X-Forwarded-For. maxAttempts and windowSeconds are checked the same way and must both be at least 1: a window of zero has no length to divide the clock into, and a negative one stores the counter already expired, so nothing is ever counted while the X-RateLimit-* headers keep looking healthy.

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 cache must count atomically, and construction enforces it. RateLimitMiddleware requires the given cache to implement Kinetis\SimpleCache\AtomicCounterInterfaceRedisSimpleCache and ClusteredRedisSimpleCache do — and throws Exception\RateLimitUnavailableException at construction for any cache that doesn’t, NullSimpleCache included.

Without it, the only way PSR-16 alone allows counting is reading the value and writing it back, which is not safe across processes: every request in flight reads the same number before any of them writes, so each believes it is the first. Measured against a real Redis, that fallback let a limit of 5 admit all 40 requests that arrived together — a limiter that stops applying under the exact concurrency it exists to resist, so this fails at boot rather than behind a flag the application has to remember to check.

Implementing the interface yourself is two methods, increment() and count(), and worth it for any backend with a native atomic increment.

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.

It counts through the same atomic primitive as the base class, and is 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.