Middleware Architecture
Reviewed & published by Brayan K
By the end of this lesson you'll understand the request/response pipeline behind every modern PHP framework — the "onion" model, the PSR-7 and PSR-15 standards, and how to build and chain your own auth, logging, and CORS middleware.
Part of the free PHP course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn in This Lesson
- Describe the request/response pipeline as an onion of wrapping layers
- Read the PSR-7 (messages) and PSR-15 (middleware) interfaces
- Build middleware that runs code before and after the controller
- Chain and dispatch middleware so pipe-order equals run-order
- Short-circuit the pipeline to reject auth, preflight, or rate-limited requests
- Know where Laravel, Slim, and Symfony plug middleware in
1️⃣ The Onion: Code Before and After
Every web app does the same core job: take an HTTP request, run your code, return an HTTP response. Middleware is a layer that wraps that code so it can run before the request reaches your controller and after the response comes back. Picture an onion: the request travels inward through each layer to the centre (your controller), then the response travels back outward through the same layers in reverse. The key piece is $next — a callback that means "hand control to the next layer in". Code before $next runs on the way in; code after it runs on the way out.
<?php
// A request travels INTO the handler, then a response travels back OUT.
// One middleware "wraps" the controller: it runs code before AND after.
// The controller is the innermost layer — it produces the response.
$controller = function (array $request): array {
return ["status" => 200, "body" => "Hello, {$request['user']}!"];
};
// A logging middleware wraps it. $next is "the rest of the pipeline".
$logging = function (array $request, callable $next): array {
echo "[in ] {$request['method']} {$request['path']}\n"; // BEFORE: on the way in
$response = $next($request); // hand off to $controller
echo "[out] status {$response['status']}\n"; // AFTER: on the way out
return $response; // pass the response back
};
// Run it: the middleware calls $next, which is the controller.
$response = $logging(
["method" => "GET", "path" => "/home", "user" => "Sam"],
$controller
);
echo "Body: {$response['body']}\n";Look at the output order: [in ] prints first, then control flows into the controller, then [out] prints. The same closure runs code on both sides of the single $next($request) call. That one idea scales to a whole stack.
2️⃣ PSR-7, PSR-15 & Chaining the Stack
Real PHP doesn't pass arrays around — it uses PSR-7, a shared standard for HTTP message objects (ServerRequestInterface and ResponseInterface), so every library agrees on what a request and response look like. On top of that, PSR-15 defines the middleware contract: a MiddlewareInterface with one method, process($request, $handler), that returns a response. Because the contract is standardised, middleware from any package can share one pipeline. Below, the request and response are kept as plain arrays so the focus stays on the pipeline — the part that chains many layers and dispatches a request through them. Notice how pipe() returns $this so calls chain, and how process() composes the stack inside-out with array_reverse so the first middleware you pipe ends up outermost.
<?php
// Real frameworks use PSR-15: a middleware has a single handle($request, $next).
// Here we model $request/$response as arrays to keep the focus on the PIPELINE.
interface Middleware
{
// $next is a callable: "process the rest of the stack and give me a response".
public function handle(array $request, callable $next): array;
}
final class Pipeline
{
/** @var Middleware[] */
private array $stack = [];
public function pipe(Middleware $mw): self
{
$this->stack[] = $mw;
return $this; // return $this so calls can be chained
}
public function process(array $request, callable $controller): array
{
// Compose inside-out: wrap the controller with the LAST middleware first,
// so the FIRST one you piped ends up on the OUTSIDE and runs first.
$next = $controller;
foreach (array_reverse($this->stack) as $mw) {
$current = $next; // capture the inner layer for this closure
$next = fn (array $req) => $mw->handle($req, $current);
}
return $next($request);
}
}
final class LoggingMiddleware implements Middleware
{
public function handle(array $request, callable $next): array
{
echo "[LOG] {$request['method']} {$request['path']}\n";
$response = $next($request); // continue the pipeline
echo "[LOG] responded {$response['status']}\n";
return $response;
}
}
final class AuthMiddleware implements Middleware
{
public function handle(array $request, callable $next): array
{
if (empty($request["headers"]["Authorization"])) {
echo "[AUTH] rejected — no token\n";
return ["status" => 401, "body" => "Unauthorized"]; // short-circuit: no $next
}
$request["user"] = "Alice"; // pretend we decoded the token
echo "[AUTH] ok — {$request['user']}\n";
return $next($request); // authorised: continue
}
}
// Pipe order = run order. Logging is outermost, Auth is inside it.
$pipeline = (new Pipeline())
->pipe(new LoggingMiddleware())
->pipe(new AuthMiddleware());
$controller = fn (array $req) => ["status" => 200, "body" => "Users for {$req['user']}"];
echo "--- request WITH token ---\n";
$ok = $pipeline->process(
["method" => "GET", "path" => "/api/users", "headers" => ["Authorization" => "Bearer x"]],
$controller
);
echo "final: {$ok['status']} {$ok['body']}\n\n";
echo "--- request WITHOUT token ---\n";
$blocked = $pipeline->process(
["method" => "GET", "path" => "/api/users", "headers" => []],
$controller
);
echo "final: {$blocked['status']} {$blocked['body']}\n";Trace the output. With a token: Logging runs (outermost), then Auth passes you through, then the controller answers, then control unwinds back out through Auth and Logging. Without a token, Auth returns a 401 and never calls $next — the controller never runs. That's a short-circuit, and it's how you cheaply reject bad requests before they touch your database. The real MiddlewareInterface looks almost identical — swap the arrays for PSR-7 objects and rename handle to process.
3️⃣ CORS: Working on the Response
Not all middleware works on the request — plenty of it shapes the response on the way out. CORS (Cross-Origin Resource Sharing) is the classic example: when a browser on app.example.com calls your API on a different domain, it needs Access-Control-Allow-* headers on the response or it will block the result. Browsers also send a "preflight" OPTIONS request first to ask permission — and your CORS middleware should answer that immediately, short-circuiting before the real handler runs.
<?php
// CORS middleware adds cross-origin headers to the RESPONSE — work done AFTER $next.
// It also answers a browser "preflight" (an OPTIONS request) without hitting the app.
final class CorsMiddleware
{
public function __construct(private string $allowOrigin) {}
public function handle(array $request, callable $next): array
{
// Browsers send OPTIONS first to ask "am I allowed?". Reply immediately.
if ($request["method"] === "OPTIONS") {
return ["status" => 204, "headers" => $this->corsHeaders(), "body" => ""];
}
$response = $next($request); // run the real handler
// Merge CORS headers onto whatever the handler returned.
$response["headers"] = ($response["headers"] ?? []) + $this->corsHeaders();
return $response;
}
private function corsHeaders(): array
{
return [
"Access-Control-Allow-Origin" => $this->allowOrigin,
"Access-Control-Allow-Methods" => "GET, POST, OPTIONS",
];
}
}
$cors = new CorsMiddleware("https://app.example.com");
$handler = fn (array $req) => ["status" => 200, "body" => "data"];
$res = $cors->handle(["method" => "GET"], $handler);
echo "GET -> {$res['status']} origin={$res['headers']['Access-Control-Allow-Origin']}\n";
$pre = $cors->handle(["method" => "OPTIONS"], $handler);
echo "OPTIONS preflight -> {$pre['status']} (handler never ran)\n";The GET flows through to the handler and then gets CORS headers merged onto its response. The OPTIONS preflight is answered with a 204 and the handler is never called. Same middleware, two paths — exactly the flexibility the onion gives you.
4️⃣ Your Turn: Call $next
Now you build a timing middleware. The whole trick is to do work, call $next, then do more work with the response. Fill in each ___ using the 👉 hint, run it, and check it against the Output panel.
<?php
// 🎯 YOUR TURN — finish this timing middleware, then run it.
// A timer measures how long the rest of the pipeline takes. To do that it must
// record the time, CALL THE NEXT LAYER, then measure again.
$timing = function (array $request, callable $next): array {
$start = microtime(true);
// 1) Run the rest of the pipeline and capture its response.
$response = ___; // 👉 call the next layer: $next($request)
$ms = round((microtime(true) - $start) * 1000, 1);
echo "[time] {$request['path']} took {$ms}ms\n";
// 2) Always hand the response back to the layer outside you.
return ___; // 👉 return the response you captured above
};
$controller = fn (array $req) => ["status" => 200, "body" => "ok"];
$result = $timing(["path" => "/report"], $controller);
echo "status: {$result['status']}\n";One more, and this one is about order. An error handler can only catch what's inside it, so it must wrap the layer that throws. Fill the blank so the error handler runs first and the throwing handler is its $next.
<?php
// 🎯 YOUR TURN — fix the ORDER so errors are caught.
// The controller throws. Only the ErrorHandler can turn that into a 500 response,
// so it MUST sit on the OUTSIDE of the layer that throws.
final class ErrorHandler
{
public function handle(array $request, callable $next): array
{
try {
return $next($request);
} catch (\Throwable $e) {
echo "[error] caught: {$e->getMessage()}\n";
return ["status" => 500, "body" => "Server Error"];
}
}
}
$throws = function (array $req): array {
throw new \RuntimeException("database is down");
};
$error = new ErrorHandler();
// 👉 Wrap the throwing handler with the error handler.
// Fill the blank so $error->handle runs FIRST and $throws is its $next.
$response = ___; // 👉 e.g. $error->handle(["path" => "/users"], $throws)
echo "final: {$response['status']} {$response['body']}\n";
// ✅ Expected output:
// [error] caught: database is down
// final: 500 Server ErrorCommon Errors (and the fix)
- The request reaches the controller but the response is empty / the page hangs — a middleware forgot to call $next (or forgot to return what it returned). If a layer doesn't call and return $next($request), the pipeline stops dead there. Only skip $next when you mean to short-circuit — and then you must return a real response.
- Exceptions escape as a 500 with a stack trace, or auth runs on unvalidated data — your middleware is in the wrong order. The error handler must be outermost (piped first) so it wraps everything; authentication must come before authorization; CORS must be able to add headers even when an inner layer short-circuits. Remember: first piped = outermost = runs first on the way in.
- "Cannot modify header information — headers already sent by ..." — you tried to add a header (or change the response) after output was already flushed. In real PHP, once any echo or whitespace before <?php has been sent, headers are locked. Do all response/header work in middleware before the body is emitted, and never leave blank lines after a closing ?>.
- Errors silently vanish — a request "succeeds" but nothing happened — a middleware swallowed an exception with an empty catch and returned a fake success. Catch only what you can handle, log the rest, and re-throw or return a proper error status. A swallowed error is far harder to debug than one that's reported.
Pro Tips
- 💡 Implement PSR-15 for real apps. A class that implements Psr\Http\Server\MiddlewareInterface drops straight into Slim, Mezzio, or any PSR-15 dispatcher — no glue code.
- 💡 Keep middleware thin. Don't run heavy database queries in middleware that applies to static assets. Use route groups so a layer only runs where it's needed.
- 💡 Treat requests as immutable. PSR-7 objects are immutable: methods like withAttribute() return a copy. Always pass the new request into $next, not the original.
📋 Quick Reference — Middleware
| Concept | In code | What it does |
|---|---|---|
| $next | $next($request) | Pass control to the next layer in |
| before | code above $next | Runs on the way in (e.g. auth check) |
| after | code below $next | Runs on the way out (e.g. add headers) |
| short-circuit | return $resp; | Return without calling $next (401, 204, 429) |
| PSR-7 | ResponseInterface | Standard HTTP message objects |
| PSR-15 | MiddlewareInterface | Standard middleware: process($req, $handler) |
| pipe order | first = outermost | First piped runs first on the way in |
Mini-Challenge: Rate Limiter
No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. A rate limiter is real middleware you'll meet on every public API.
<?php
// 🎯 MINI-CHALLENGE: a rate-limiting middleware.
// No code is filled in — work from the steps, then run it.
//
// 1. Keep a static counter of how many requests an IP has made
// (a simple static array $hits = []; inside the method works).
// 2. In handle($request, $next):
// - read $request["ip"], increment $hits[$ip]
// - if the count is > 3, return ["status" => 429, "body" => "Too Many Requests"]
// - otherwise call $next($request) and return its response
// 3. Send 5 requests from the same IP through it and print each status.
//
// Tip: a 429 means "rate limited". The first 3 should pass, the rest should fail.
//
// ✅ Expected output (handler returns 200):
// request 1 -> 200
// request 2 -> 200
// request 3 -> 200
// request 4 -> 429
// request 5 -> 429
// your code here🎉 Lesson Complete!
- ✅ Middleware wraps your controller in an onion: requests flow in, responses flow back out through the same layers
- ✅ $next($request) hands control to the next layer; code before it runs in, code after it runs out
- ✅ PSR-7 standardises HTTP messages; PSR-15 standardises the middleware contract so layers from any package compose
- ✅ A pipeline composes the stack inside-out — the first piece you pipe is outermost and runs first
- ✅ Short-circuit by returning a response without calling $next (auth 401, CORS preflight 204, rate-limit 429)
- ✅ Order matters: error handler outermost, auth before authorization, and never modify the response after it's been sent
Practice quiz
What is middleware in a PHP request/response pipeline?
- A database table that stores requests
- A template engine for rendering HTML
- A layer of code that wraps your controller, running before the request reaches it and after the response comes back
- A tool that compiles PHP to machine code
Answer: A layer of code that wraps your controller, running before the request reaches it and after the response comes back. Middleware wraps the controller like an onion: the request travels inward through each layer, then the response travels back outward.
What does the $next callback represent inside a middleware?
- A callback that hands control to the next layer in the pipeline
- The previous middleware that already ran
- The database connection
- The HTTP status code
Answer: A callback that hands control to the next layer in the pipeline. $next means 'process the rest of the stack and give me a response' — code before it runs on the way in, code after it on the way out.
In a middleware, where does code that runs 'on the way out' go relative to $next?
- Before the $next($request) call
- Inside a separate Footer() method
- It cannot run after $next
- After the $next($request) call
Answer: After the $next($request) call. Code before $next runs on the way in (e.g. an auth check); code after $next runs on the way out (e.g. adding headers).
What does it mean for a middleware to 'short-circuit' the pipeline?
- It calls $next twice
- It returns a response WITHOUT calling $next, so the rest of the pipeline and the controller never run
- It throws an exception to skip ahead
- It restarts the request from the beginning
Answer: It returns a response WITHOUT calling $next, so the rest of the pipeline and the controller never run. Short-circuiting returns a response without $next — how auth returns 401, CORS answers a 204 preflight, or a rate limiter returns 429.
When the AuthMiddleware finds no Authorization header, what does it do?
- Returns a 401 response and never calls $next, so the controller never runs
- Calls $next($request) anyway
- Logs the user in as a guest
- Throws a fatal error
Answer: Returns a 401 response and never calls $next, so the controller never runs. It short-circuits with a 401 'Unauthorized' and never calls $next, cheaply rejecting the request before it hits the database.
In the Pipeline, why is the stack composed inside-out with array_reverse?
- To run middleware in random order
- To improve performance
- So the FIRST middleware you pipe ends up on the OUTSIDE and runs first
- Because array_reverse is required by PSR-15
Answer: So the FIRST middleware you pipe ends up on the OUTSIDE and runs first. Wrapping the controller with the last middleware first makes the first one piped the outermost, so pipe order equals run order.
Why must an error-handling middleware sit on the OUTSIDE of the layer that throws?
- Because it is alphabetically first
- Because it can only catch exceptions thrown by layers INSIDE it (in its $next)
- Because outer middleware runs faster
- Because PSR-15 forbids inner error handlers
Answer: Because it can only catch exceptions thrown by layers INSIDE it (in its $next). An error handler wraps everything inside it in try/catch, so it must be outermost to catch exceptions thrown anywhere deeper.
What does PSR-15 standardise?
- The HTTP message objects (request and response)
- The database query syntax
- The autoloader format
- The middleware contract — MiddlewareInterface with process($request, $handler) returning a response
Answer: The middleware contract — MiddlewareInterface with process($request, $handler) returning a response. PSR-15 defines the middleware contract; PSR-7 standardises the HTTP message objects it operates on.
What does a CORS middleware do for an OPTIONS preflight request?
- Forwards it to the controller like any other request
- Answers it immediately with a 204 and the CORS headers, short-circuiting before the real handler runs
- Rejects it with a 401
- Converts it to a GET request
Answer: Answers it immediately with a 204 and the CORS headers, short-circuiting before the real handler runs. Browsers send OPTIONS first to ask permission; the CORS middleware replies with a 204 and headers without hitting the app.
If a middleware forgets to call (or return) $next, what is the likely symptom?
- The request is automatically retried
- The controller runs twice
- The response is empty or the page hangs because the pipeline stops dead there
- A 500 error with a stack trace
Answer: The response is empty or the page hangs because the pipeline stops dead there. Without calling and returning $next($request), the pipeline halts at that layer — only skip $next when you mean to short-circuit.
Continue this course
- Previous: Building Your Own Micro MVC Framework
- Next: Advanced PDO: Transactions, Prepared Statements & Stored Procedures — Use PDO for safe, efficient database access with transactions and stored procs
- Quick reference: PHP cheat sheet
Frequently asked questions
What exactly is middleware in PHP?
Middleware is a layer of code that sits between the incoming HTTP request and your application's controller. Each piece of middleware receives the request and a $next callback; it can read or change the request, call $next to pass control deeper into the stack, inspect or change the response on the way back out, or return a response early to stop the request before it reaches the controller. Authentication, logging, CORS, and rate limiting are all classic middleware jobs because they apply to many routes and are easiest to handle in one shared place.
What are PSR-7 and PSR-15?
They are PHP-FIG standards that let middleware from different libraries work together. PSR-7 defines immutable interfaces for HTTP messages (ServerRequestInterface and ResponseInterface) so a request or response object looks the same everywhere. PSR-15 builds on it with two tiny interfaces: MiddlewareInterface, whose process(ServerRequestInterface $request, RequestHandlerInterface $handler) method returns a ResponseInterface, and RequestHandlerInterface, which is the $handler you delegate to. Because the contract is standardised, you can mix middleware from any PSR-15 package in the same pipeline.
Why does the order of middleware matter so much?
Because middleware forms an onion: the first one you add is the outermost layer, so it runs first on the way in and last on the way out. An error handler must be outermost so it can catch exceptions thrown anywhere inside. Authentication must run before authorization, because you can't check a user's permissions before you know who they are. CORS headers must be added on the response even when an inner layer short-circuits. Put the layers in the wrong order and you get exceptions that escape, valid requests rejected by the browser, or auth checks that run on data that was never validated.
What does it mean to short-circuit a pipeline?
It means a middleware returns a response without calling $next, so the rest of the pipeline and the controller never run. Auth middleware short-circuits with a 401 when there's no valid token; a CORS middleware short-circuits an OPTIONS preflight with a 204; a rate limiter short-circuits with a 429. Short-circuiting is a feature, not a bug — it's how you reject bad requests cheaply, before they reach expensive database work.
Where do real frameworks use middleware?
Everywhere a request enters the app. Laravel runs global middleware plus named groups like 'web' and 'api', and lets you attach middleware to individual routes or route groups. Slim and Mezzio are built directly on PSR-15 pipelines. Symfony uses a closely related idea called HttpKernel event listeners. Under the hood they all do what the examples in this lesson do: wrap the controller in a stack of layers and dispatch the request through it.