HTTP & container
The middleware pipeline, and where the response comes back through
The mistake
Middleware looks like a checklist. The request arrives and runs down a list of classes. Each one lets it through or rejects it, and whatever survives reaches the controller. People picture a straight line of filters, each doing its work once, on the way in.
That misses half of what middleware is. Each middleware wraps the request and the response. The code you write before handing the request on runs on the way in. The code you write after runs on the way back out, once the controller has produced a response and it is travelling back to the browser. Middleware is not a line. It is an onion, and every layer is entered twice.
The machine
Every step, the response status and each short-circuit run on the tested reducer, matching how Laravel's middleware pipeline wraps the request and the response.
Drive it
The panel boots with a logged-in POST that has already passed down through all four
middleware and reached the controller. The controller returned a 200, and that
response is now sitting at the centre, ready to come back out.
- Press the highlighted button and keep going. The
200walks back up through the middleware in reverse.StartSessionsaves the session,EncryptCookiesencrypts the cookies. The two inner guards have no after half, so the response passes straight through them. - Now log the user out and advance again. The request reaches
Authenticate, which redirects to the login page without ever calling the controller. Watch the redirect still travel back out through the middleware that already ran. - Use a stale CSRF token and send one more. This time it is turned away two layers earlier, at the CSRF check. The deeper a request gets before it is rejected, the more of the onion the response has to unwind through.
The mechanism
Every middleware has the same shape, and one line carries the whole idea:
public function handle(Request $request, Closure $next): Response
{
// before: runs on the way in
$response = $next($request); // hand off to the layer inside this one
// after: runs on the way out
return $response;
}
$next is the rest of the pipeline, rolled up into a single closure. Calling it
hands the request to the next middleware in, which calls its own $next, and so
on down to the controller. Laravel builds this with a pipeline class. The HTTP
kernel uses Illuminate\Routing\Pipeline, which is a subclass of
Illuminate\Pipeline\Pipeline. Either way it folds the middleware list into
nested closures, so “the next layer” is literally a function each middleware
calls.
That nesting is why the two halves run in opposite orders. The before halves run
outer to inner, in the order the middleware are listed. Then the controller runs.
Then each $next($request) call returns, so the after halves run inner to outer, in
reverse. The last middleware to touch the request is the first to see the response.
Now the part that surprises people. A middleware can decide not to call $next:
public function handle(Request $request, Closure $next): Response
{
if (! $request->user()) {
return redirect('/login'); // returns here, $next is never called
}
return $next($request);
}
When Authenticate does this, three things happen at once. The controller and
every middleware inside this one never run, because reaching them meant calling
$next. This middleware’s own after half never runs either, because it is
written after the $next line and the method returned before reaching it. But
every middleware outside this one already called $next, and each is still
waiting on that line. So the response travels back out through all of their
after halves as normal. That is why the login redirect still gets its session
saved and its cookies encrypted on the way out. It is a real response unwinding
through the outer layers, not a special exit.
An abort thrown deep in the stack, like the TokenMismatchException from the
CSRF check, ends up in the same place. Every stage is wrapped in a
try/catch. The base Illuminate\Pipeline\Pipeline simply rethrows what it
catches. The routing subclass does more. It passes the exception to the
registered handler, renders it into a response, and returns that as the stage’s
result. The response then travels back up the stack the pipeline had already
built. From the outer middleware’s point of view there is no difference between
a 200 from the controller and a 419 from a failed check. Both are just the
response coming back through.
In your code
Because the after half wraps the entire inner pipeline, a middleware can measure or change anything about the finished response. A timing middleware is the clearest example:
class MeasureResponseTime
{
public function handle(Request $request, Closure $next): Response
{
$start = microtime(true);
$response = $next($request); // the whole request lifecycle happens here
$ms = round((microtime(true) - $start) * 1000);
return $response->header('X-Response-Time', "{$ms}ms");
}
}
Every inner middleware and the controller itself run inside that one
$next($request) call. So a timer wrapped around it measures all of them. In
Laravel 11 you register it in bootstrap/app.php. The order you list middleware
in is the order their before halves run. Their after halves run in the reverse
of that order:
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
MeasureResponseTime::class,
]);
})
The fine print
- Real apps stack more than one group. Global middleware run first, then the
weborapigroup, then middleware attached to the individual route. The onion just has more layers; the in-then-out shape is the same. - Not every middleware touches the response. Some are before-only guards, like the CSRF and auth checks here. Some are after-only, like the one that attaches queued cookies. The pipeline still passes the response through all of them.
terminate()is a third phase this page leaves out. Terminable middleware, such asStartSession, run aterminatemethod after the response has already been sent to the browser, for cleanup that the user should never wait on.- Laravel sorts a handful of middleware into a fixed relative order with a priority list, so security-sensitive ones like authentication stay in the right place no matter how you register them.
- The CSRF middleware shown here is the classic
VerifyCsrfToken. Laravel 11 renamed the framework class toValidateCsrfToken, but its job in the pipeline is unchanged.
Further reading
- Laravel: middleware is the manual for
handle,$next, groups, aliases and registering middleware inbootstrap/app.php. - Laravel: request lifecycle traces the whole
path from
public/index.phpthrough the kernel and the pipeline to your route. - Laravel API: the Pipeline
is the class that folds the middleware list into the nested closures this page models.
Read
Illuminate\Routing\Pipelinenext to it: that subclass is what the HTTP kernel runs, and it is where a thrown exception becomes a response instead of escaping.
Spotted a problem, or have a way to make this clearer? Suggest an improvement.