Middleware¶
Kinetis’s middleware is plain 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.
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.
{
"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.
MAX_BODY_SIZE=2097152
Bytes, not a "2M"-style string. Defaults to 2097152 (2 MiB) when
unset.
{
"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:
{
"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']);
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¶
Container —
AppScope/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
AuthMiddlewarepattern 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_PATHSfollows.