HTTP & container

The middleware pipeline, and where the response comes back through

The mistake

Middleware looks like a checklist. The request arrives, it 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

Simulator · Middleware pipeline

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.

The controller runs and returns a 200 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 200 walks back up through the middleware in reverse. StartSession saves the session, EncryptCookies encrypts 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 Illuminate\Pipeline\Pipeline, or rather with Illuminate\Routing\Pipeline, the subclass the HTTP kernel actually uses. 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 we returned before it. But every middleware outside this one already called $next and 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; it is the routing subclass that passes the exception to the registered handler, renders it into a response, and returns that as the stage’s result, so it 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");
    }
}

Everything the request does, every inner middleware and the controller itself, happens inside that one $next($request) call, so a timer wrapped around it measures all of it. In Laravel 11 you register it in bootstrap/app.php, where the order you list middleware is the order their before halves run, and the reverse of the order their after halves run:

->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 web or api group, 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 as StartSession, run a terminate method 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 to ValidateCsrfToken, but its job in the pipeline is unchanged.

Further reading

  • Laravel: middleware is the manual for handle, $next, groups, aliases and registering middleware in bootstrap/app.php.
  • Laravel: request lifecycle traces the whole path from public/index.php through 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\Pipeline next 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.