Middleware: The Request/Response Pipeline
Understand middleware as layered wrappers around a core request handler — the 'onion model' — and see real middleware for authentication, authorization, logging, CORS, rate limiting, request IDs, and content negotiation.
A router gets a request to the right controller, but plenty of logic needs to run around every matched route, not inside any one of them — checking who's logged in, logging every request, deciding whether cross-origin JavaScript is allowed to call the API, and more. Middleware is the pattern for inserting that shared logic into the request/response pipeline without duplicating it inside every controller.
The onion model
Picture the core request handler (the controller) as the center of an onion, and each middleware as a layer wrapped around it. A request comes in from the outside and passes inward through each layer in order; the response then passes back outward through the same layers, in reverse order, before finally leaving the application.
Concretely, each middleware is a function or object with a single method — commonly named handle($request, $next) — that receives the request and a callable ($next) representing 'the rest of the pipeline, including the eventual controller'. A middleware can inspect or modify the request before calling $next($request), inspect or modify the Response that $next() returns, or short-circuit entirely by returning its own Response without ever calling $next() at all (for example, an auth middleware returning 401 before the controller ever runs).
Why order matters
Because each middleware wraps everything inside it, the order middleware is registered in changes behavior. A logging middleware placed outermost times the entire pipeline including auth and the controller; placed innermost, it only times the controller itself. An authentication middleware must run before an authorization middleware that checks 'is this authenticated user allowed to do this' — authorization has nothing to check if authentication hasn't run yet.
A typical ordering for an API might be: request ID assignment (so even error responses can be correlated) → logging (start) → CORS handling → rate limiting → authentication → authorization → content negotiation → the controller — then unwinding back outward through the same layers for the response.
Authentication vs authorization middleware
These are frequently confused but distinct. Authentication answers 'who is making this request?' — typically by validating a session cookie or a bearer token and attaching the resolved user (or null) to the request. Authorization answers a different question: 'is this specific, already-identified user allowed to do this specific thing?' — and therefore must run after authentication has determined who they are.
Keeping them as separate middleware means an endpoint can require authentication without a specific role (any logged-in user), or can layer an authorization check on top only where it's actually needed (e.g. only admin routes get an AdminOnly middleware, while all authenticated routes share the same auth middleware).
CORS, rate limiting, request IDs, and content negotiation
CORS (Cross-Origin Resource Sharing) middleware decides which browser-based origins are allowed to call this API from JavaScript, by inspecting the Origin request header and, if permitted, adding Access-Control-Allow-Origin (and related) headers to the response — and short-circuiting an OPTIONS 'preflight' request with just those headers, before it ever reaches a controller.
Rate limiting middleware tracks how many requests a given client (by IP, API key, or user id) has made in a time window, and returns 429 Too Many Requests once they exceed a limit — protecting the application from abuse or accidental request storms, independent of what any individual route does.
A request ID middleware generates (or reads from an incoming X-Request-Id header) a unique identifier for the request, attaches it to the request for other layers and log lines to reference, and echoes it back in the response header — this is what lets you correlate a single request across logs, error trackers, and a support ticket.
Content negotiation middleware reads the client's Accept header and decides what format the response body should take (JSON vs XML vs plain text), so the same controller logic can serve different clients their preferred representation without every controller re-implementing that check.
The middleware interface and pipeline runner
array_reverse ensures the middleware listed first in the array is the outermost wrapper — the first thing that sees the request and the last thing that sees the response. $coreHandler is the innermost function: typically 'run the matched controller action'. Each $next is a closure capturing the *next* layer inward, which is exactly what makes the onion model composable from a flat array.
interface Middleware
{
// $next is a callable: fn(Request $request): Response
public function handle(Request $request, callable $next): Response;
}
final class Pipeline
{
/** @param Middleware[] $middleware */
public function __construct(private array $middleware) {}
public function run(Request $request, callable $coreHandler): Response
{
// build the chain from the inside out, so the FIRST middleware
// registered ends up as the OUTERMOST layer
$next = $coreHandler;
foreach (array_reverse($this->middleware) as $mw) {
$next = fn (Request $req): Response => $mw->handle($req, $next);
}
return $next($request);
}
}Code Examples
RequestIdMiddleware demonstrates the 'wrap both directions' shape: it sets something up before calling $next, then modifies the response that comes back. CorsMiddleware demonstrates short-circuiting: an OPTIONS preflight request never reaches $next (the controller) at all — it gets an empty 204 straight away, with only the CORS headers attached.
if ($request->method() === 'OPTIONS') { $response = new Response('', 204); } else { $response = $next($request); } is the short-circuit: the pipeline can stop before reaching the core handler.
AuthenticationMiddleware resolves WHO is making the request and attaches $user to the request, or returns 401 immediately. AuthorizationMiddleware, registered after it in the pipeline, checks WHAT that already-identified user is allowed to do, returning 403 if their roles don't include the required one — it relies entirely on authentication having already run. RateLimitMiddleware short-circuits with 429 once a client (by user id, falling back to IP) exceeds a request budget within a time window.
Common mistake: Registering AuthorizationMiddleware before AuthenticationMiddleware means $request->attributes['user'] is never set yet when authorization checks it, so every request would incorrectly be treated as unauthorized (or would require awkward null-handling in every authorization check).
This middleware sits close to the controller: it lets the controller return plain data and decides, based on the client's Accept header, whether to serialize that data as JSON or XML on the way back out — so individual controllers never need to special-case content types themselves.
$data = $response->data(); pulls the controller's raw result out of the Response so this middleware, not the controller, decides the wire format.
<?php
declare(strict_types=1);
final class RequestIdMiddleware implements Middleware
{
public function handle(Request $request, callable $next): Response
{
$id = $request->header('X-Request-Id') ?? bin2hex(random_bytes(8));
$request->attributes['request_id'] = $id;
$response = $next($request);
return $response->withHeader('X-Request-Id', $id);
}
}
final class LoggingMiddleware implements Middleware
{
public function __construct(private \Psr\Log\LoggerInterface $logger) {}
public function handle(Request $request, callable $next): Response
{
$start = microtime(true);
$response = $next($request);
$durationMs = (microtime(true) - $start) * 1000;
$this->logger->info(sprintf(
'[%s] %s %s -> %d (%.1fms)',
$request->attributes['request_id'] ?? '-',
$request->method(),
$request->path(),
$response->status(),
$durationMs,
));
return $response;
}
}
final class CorsMiddleware implements Middleware
{
private const ALLOWED_ORIGINS = ['https://example.com', 'http://localhost:3000'];
public function hand
…<?php
declare(strict_types=1);
final class AuthenticationMiddleware implements Middleware
{
public function __construct(private TokenService $tokens) {}
public function handle(Request $request, callable $next): Response
{
$header = $request->header('Authorization') ?? '';
if (!str_starts_with($header, 'Bearer ')) {
return Response::json(['error' => 'Authentication required'], 401);
}
$user = $this->tokens->resolveUser(substr($header, 7));
if ($user === null) {
return Response::json(['error' => 'Invalid or expired token'], 401);
}
$request->attributes['user'] = $user; // now known to every downstream layer
return $next($request);
}
}
final class AuthorizationMiddleware implements Middleware
{
public function __construct(private string $requiredRole) {}
public function handle(Request $request, callable $next): Response
{
$user = $request->attributes['user'] ?? null; // set by AuthenticationMiddleware
if ($user === null || !in_array($this->requiredRole, $user['roles'], true)) {
return Response::json(['error' => 'Forbidden'], 403);
…<?php
declare(strict_types=1);
final class ContentNegotiationMiddleware implements Middleware
{
public function handle(Request $request, callable $next): Response
{
$response = $next($request);
$accept = $request->header('Accept') ?? '*/*';
// Controllers return plain arrays for JSON by convention here;
// this middleware decides the actual wire format.
$data = $response->data();
if ($data === null) {
return $response; // controller already produced a fixed-format body (e.g. HTML)
}
if (str_contains($accept, 'application/xml')) {
return $response->withBody($this->toXml($data))->withHeader('Content-Type', 'application/xml');
}
// default: JSON, even for */*
return $response
->withBody(json_encode($data, JSON_THROW_ON_ERROR))
->withHeader('Content-Type', 'application/json');
}
private function toXml(array $data): string
{
$xml = new \SimpleXMLElement('<response/>');
array_walk_recursive($data, fn ($value, $key) => $xml->addChild((string) $key, (string) $value));
return $xml->asXML();
}
}The middleware onion / pipeline
Each middleware can act on the way in (before calling $next), on the way out (after $next returns), or both — and any layer can short-circuit by returning a Response without ever calling $next, which is exactly how auth, CORS preflight, and rate limiting stop a request early.
Request
│
▼
┌───────────────── RequestId ─────────────────┐
│ ┌──────────────── Logging ──────────────────┐│
│ │ ┌──────────────── CORS ───────────────────┐││
│ │ │ ┌────────────── RateLimit ─────────────┐│││
│ │ │ │ ┌──────────── Auth ──────────────────┐││││
│ │ │ │ │ ┌────────── Authorization ────────┐│││││
│ │ │ │ │ │ CONTROLLER ││││││
│ │ │ │ │ └──────────────────────────────────┘│││││
│ │ │ │ └──────────────────────────────────────┘│││
│ │ │ └────────────────────────────────────────────┘││
│ │ └──────────────────────────────────────────────────┘│
│ └────────────────────────────────────────────────────────┘
└──────────────────────────────────────────────────────────────┘
│ ▲
▼ (request travels inward) (response travels outward)│
ResponseImportant Rules
Middleware order determines behavior: outer layers see the raw request first and the final response last; put request-scoped setup (request IDs) outermost and content-shaping logic (content negotiation) closest to the controller.
A middleware can short-circuit the pipeline by returning a Response without calling $next — this is how auth failures (401), forbidden actions (403), CORS preflight (204), and rate limiting (429) stop a request before it reaches the controller.
Authentication must run before authorization — authorization needs to know who the user is before it can decide what they're allowed to do.
Rate limiting and CORS are cross-cutting concerns that belong in middleware, not duplicated inside individual controllers.
A request ID should be generated (or forwarded) as early as possible in the pipeline so every subsequent layer, including error handlers, can include it in logs.
PSR-15 and framework middleware
This handle($request, $next) shape mirrors PSR-15 (PHP-FIG's standard interface for HTTP middleware), which is what lets middleware packages be shared across different frameworks (Slim, Mezzio, and others) instead of every framework inventing an incompatible middleware format. Laravel's app/Http/Kernel.php $middleware and $middlewareGroups arrays, and Symfony's event-based kernel listeners, both implement the same onion concept with framework-specific syntax around it.
class AuthorizationMiddleware { public function handle($request, $next) { // assumes $request->attributes['user'] always exists if (!in_array('admin', $request->attributes['user']['roles'])) { return Response::json(['error' => 'Forbidden'], 403); } return $next($request); } } // registered BEFORE AuthenticationMiddleware in the pipeline array
$pipeline = new Pipeline([ new RequestIdMiddleware(), new LoggingMiddleware($logger), new CorsMiddleware(), new RateLimitMiddleware($cache), new AuthenticationMiddleware($tokens), // sets $request->attributes['user'] new AuthorizationMiddleware('admin'), // now safe to read it ]);
Because middleware runs in registration order on the way in, AuthorizationMiddleware must be registered after AuthenticationMiddleware so 'user' is guaranteed to be set (or the pipeline already short-circuited with a 401) by the time authorization checks it.
In the onion model, what does it mean for a middleware to 'short-circuit' the pipeline? A. It crashes the PHP process B. It calls $next() twice C. It returns its own Response without calling $next(), so no layer or controller further inward ever runs D. It skips logging only
C. It returns its own Response without calling $next(), so no layer or controller further inward ever runs — Short-circuiting means the middleware decides the request is finished — returning 401, 403, 429, or a CORS preflight 204, for example — without ever invoking $next, so everything inward of it (including the controller) never executes.
Why must an authentication middleware be registered before an authorization middleware in the pipeline? A. PHP requires alphabetical middleware order B. Authorization needs to know who the request's user is (set by authentication) before it can decide what that user may do C. Authentication is always faster to execute D. It has no effect on order; both can run in any sequence
B. Authorization needs to know who the request's user is (set by authentication) before it can decide what that user may do — Authorization answers 'is this identified user allowed to do this?' — a question that depends on authentication having already resolved and attached the user to the request.
[code] In the Pipeline::run() implementation shown, why is array_reverse($this->middleware) used before building the chain of closures? A. To improve performance B. So that the FIRST middleware in the original array ends up as the OUTERMOST layer, seeing the request first and the response last C. To randomize execution order for testing D. It has no functional effect and is purely stylistic
B. So that the FIRST middleware in the original array ends up as the OUTERMOST layer, seeing the request first and the response last — The chain is built from the inside out, wrapping $coreHandler first with the LAST middleware in the array and finishing by wrapping with the FIRST. Reversing first ensures the intuitive registration order (first item = outermost, first-seen) holds true when the pipeline actually runs.
Write a JsonBodyParsingMiddleware that, when the request's Content-Type header is application/json, decodes the raw request body into an associative array and attaches it to $request->attributes['parsed_body'] before calling $next — and returns a 400 Bad Request Response (without calling $next) if the body is present but not valid JSON.
final class JsonBodyParsingMiddleware implements Middleware { public function handle(Request $request, callable $next): Response { $contentType = $request->header('Content-Type') ?? ''; if (str_contains($contentType, 'application/json') && $request->body() !== '') { $decoded = json_decode($request->body(), true); if (json_last_error() !== JSON_ERROR_NONE) { return Response::json(['error' => 'Malformed JSON body'], 400); } $request->attributes['parsed_body'] = $decoded; } return $next($request); } }
Use json_decode($request->body(), true) and check json_last_error() !== JSON_ERROR_NONE to detect invalid JSON; only attempt parsing when the Content-Type header actually contains 'application/json'.