A chain of small pieces that each inspect or tweak the request before your controller runs. You saw a front controller in intermediate; middleware is how that entry point stays readable when auth, CSRF, logging, and rate limits all need a say.
The idea in one sentence
Each middleware receives the request, can short-circuit with a response (403, redirect), or pass control to the next link in the chain. Controllers run last.
A minimal contract
<?php
declare(strict_types=1);
namespace AppHttp;
interface Middleware
{
/** @param callable(): void $next */
public function handle(Request $request, callable $next): void;
}
Request can be a thin wrapper around superglobals and route params. Keep it boring – no framework magic required.
The pipeline runner
final class Pipeline
{
/** @param list<Middleware> $middleware */
public function __construct(private array $middleware) {}
public function run(Request $request, callable $destination): void
{
$runner = array_reduce(
array_reverse($this->middleware),
fn (callable $next, Middleware $mw) => fn () => $mw->handle($request, $next),
$destination,
);
$runner();
}
}
array_reduce builds the onion from the inside out. The destination (your controller action) sits at the core.
Example: require login
final class RequireAuth implements Middleware
{
public function handle(Request $request, callable $next): void
{
if (empty($_SESSION['user_id'])) {
header('Location: /login', true, 302);
return;
}
$next();
}
}
Example: CSRF check on POST
final class VerifyCsrf implements Middleware
{
public function handle(Request $request, callable $next): void
{
if ($request->method() === 'POST') {
$token = $_POST['_token'] ?? '';
if (!hash_equals($_SESSION['csrf'] ?? '', $token)) {
http_response_code(419);
echo 'Invalid CSRF token.';
return;
}
}
$next();
}
}
Per-route stacks
Global middleware runs on every request (security headers, session start). Route groups add more:
$routes = [
'GET /notes' => [
'middleware' => [RequireAuth::class],
'handler' => [NoteController::class, 'index'],
],
'POST /notes' => [
'middleware' => [RequireAuth::class, VerifyCsrf::class],
'handler' => [NoteController::class, 'store'],
],
];
Order matters
- Start session before auth reads
$_SESSION - Rate limiting before expensive controller work
- CSRF after you know the method is POST
- Logging outermost if you want timing for the whole stack
Keep each middleware focused on one job. When you need to change how login works, you touch one class – not twelve controllers.

