Middleware: What It Is, How It Chains, and When to Write Your Own

Middleware: What It Is, How It Chains, and When to Write Your Own

Middleware is one of Laravel’s most tested certification topics because it sits at the core of the request lifecycle. This article goes beyond basic syntax to explain how middleware works internally, how the pipeline pattern processes requests, what happens when $next is skipped, and why some middleware never executes. If you want to truly understand Laravel middleware rather than just use it, this is where to start.

Steve McDougall

Steve McDougall

May 28, 2026

Middleware: What It Is, How It Chains, and When to Write Your Own

You have used middleware hundreds of times. You have typed ->middleware('auth') on a route, seen ThrottleRequests referenced in your bootstrap file, and probably copy-pasted a custom middleware at some point without thinking too hard about how it fits into the rest of the request lifecycle. That is fine for getting things done. It is not fine for the certification.

Middleware is one of the most consistently tested topics across the mid and senior exams, and the questions are not asking whether you know the syntax. They are asking whether you understand what middleware actually does at the infrastructure level. What happens when you do not call $next. What the pipeline pattern is. Why some middleware in your stack never runs. These are the questions that require real understanding, not just familiarity.

We covered the request lifecycle in the second article in this series. Middleware is the meat of that lifecycle, so if you have not read that one, now is a good time. This article picks up from there and goes deeper.


Middleware Is a Pipeline

The most important mental model to have is this: Laravel's middleware stack is a pipeline. Each middleware wraps the next one, forming a set of nested layers around your controller. The request passes inward through each layer, hits the controller, and then the response travels back out through the same layers in reverse.

The Pipeline class in Illuminate\\Pipeline is what makes this work, and it is used for more than just HTTP middleware. The router uses it for route middleware. The queue uses it for job middleware. You will see the same pattern across the framework once you know what to look for.

Here is the simplified version of what Pipeline does under the hood:

      // Conceptually, the pipeline builds something like this
$response = $middleware1->handle($request, function ($request) use ($middleware2, $controller) {
    return $middleware2->handle($request, function ($request) use ($controller) {
        return $controller->action($request);
    });
});

    

Each middleware receives the request and a $next closure. Calling $next($request) executes the rest of the pipeline and returns the response. This gives you hooks on both sides: before you call $next, you are in the inbound path. After, you are in the outbound path.

      public function handle(Request $request, Closure $next): Response
{
    // Inbound: runs before the controller
    Log::info('Request incoming', ['url' => $request->url()]);

    $response = $next($request);

    // Outbound: runs after the controller, on the way back
    $response->header('X-Processed-By', 'MyApp');

    return $response;
}

    

That return at the end matters. If you forget to return the response, you break the chain. This is one of the most common exam traps.


What Happens When You Do Not Call $next

This is question 755 in the senior bank, and it is a trap worth understanding in detail.

When a middleware returns early without calling $next($request), the entire rest of the pipeline is skipped. Every middleware that comes after it in the stack never runs. The controller never runs. The response that early-returning middleware produces is what gets sent to the client.

      // EnsureUserIsAuthenticated
public function handle(Request $request, Closure $next): Response
{
    if (!$request->user()) {
        return redirect('/login'); // $next is never called
    }

    return $next($request);
}

    

If this middleware is registered before LogRequestDetails in your stack, and an unauthenticated request comes in, LogRequestDetails never executes. Not the inbound part, not the outbound part. It is completely bypassed.

This has real consequences. Logging middleware, response-modifying middleware, CORS headers, anything that lives after an early-returning middleware in the stack will not fire for that request. Understanding the ordering of your middleware stack is not a cosmetic concern.


The Three Middleware Layers

Laravel organises middleware into three layers, and they run in a specific order. Getting this wrong in a production application tends to produce strange, hard-to-reproduce bugs.

Global middleware runs on every request, full stop. In Laravel 11 and 12 this is registered in bootstrap/app.php:

      ->withMiddleware(function (Middleware $middleware) {
    $middleware->append(TrustHosts::class);
    $middleware->append(ForceHttps::class);
})

    

Use prepend to put something at the front of the global stack, append to put it at the end. Use appendToGroup to slot a class into an existing group.

Route group middleware is applied via the router and only runs on matched routes. The built-in web and api groups contain the standard session, cookie, and authentication middleware. When you write ->middleware('auth'), you are referencing an alias that resolves to Authenticate::class.

Route-level middleware is applied directly to individual routes or route groups you define yourself.

The order of execution is global first, then group, then route-level. Within each layer, middleware runs in the order it was registered.


Writing Your Own Middleware

Creating a middleware is one of the simpler tasks in Laravel:

      php artisan make:middleware EnsureEmailIsVerified

    

The generated class has a single handle method. Here is the full signature:

      public function handle(Request $request, Closure $next, string ...$guards): Response

    

Those variadic $guards at the end are middleware parameters, passed via the route definition:

      Route::middleware('role:admin,editor')->group(function () {
    // ...
});

    

The colon separates the middleware alias from its parameters. Multiple parameters are comma-delimited, and they arrive in your handle method as the variadic argument after $next.

      public function handle(Request $request, Closure $next, string ...$roles): Response
{
    if (!$request->user()->hasAnyRole($roles)) {
        abort(403);
    }

    return $next($request);
}

    

Register it in bootstrap/app.php with an alias:

      ->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'role' => EnsureUserHasRole::class,
    ]);
})

    

Terminable Middleware

Most middleware is done when handle returns. But sometimes you want to run logic after the response has been sent to the client. That is what terminable middleware is for.

      class LogResponseTime implements TerminableMiddleware
{
    private float $startTime;

    public function handle(Request $request, Closure $next): Response
    {
        $this->startTime = microtime(true);
        return $next($request);
    }

    public function terminate(Request $request, Response $response): void
    {
        $duration = microtime(true) - $this->startTime;
        Log::info('Response sent', ['ms' => round($duration * 1000, 2)]);
    }
}

    

The terminate method is called by the kernel after $response->send(). The client has already received their response by the time terminate runs. This makes it appropriate for things like sending analytics, flushing slow logs, or any cleanup that should not hold up the response.

One thing to be aware of: for terminate to work correctly, the middleware class needs to be resolved as a singleton from the container. If the container creates a fresh instance for each pipeline stage (which it can do depending on how you have things configured), the $startTime property set in handle will not be on the same instance that terminate is called on. Register terminable middleware as a singleton in a service provider if you are relying on instance state across the two methods.


Middleware Groups

Middleware groups are just named arrays of middleware classes. They let you apply a set of middleware to routes without listing each one individually. The built-in web and api groups are the obvious examples, but you can define your own:

      ->withMiddleware(function (Middleware $middleware) {
    $middleware->appendToGroup('api-authenticated', [
        'auth:sanctum',
        EnsureEmailIsVerified::class,
        ThrottleRequests::class . ':60,1',
    ]);
})

    

Then on your routes:

      Route::middleware('api-authenticated')->group(function () {
    Route::apiResource('posts', PostController::class);
});

    

Groups can contain other groups, aliases, class names, or middleware with parameters. They are expanded at dispatch time, so the actual pipeline still runs each middleware individually.


Priority and Ordering

When middleware from different layers runs together, the order matters. Laravel allows you to explicitly set the priority of middleware classes to guarantee they run in a specific sequence regardless of how they were registered:

      ->withMiddleware(function (Middleware $middleware) {
    $middleware->priority([
        \\Illuminate\\Foundation\\Http\\Middleware\\HandlePrecognitiveRequests::class,
        \\Illuminate\\Cookie\\Middleware\\EncryptCookies::class,
        \\Illuminate\\Session\\Middleware\\StartSession::class,
        \\Illuminate\\Auth\\Middleware\\Authenticate::class,
        // ...
    ]);
})

    

The priority list is used to reorder the resolved middleware before the pipeline runs. Middleware not in the priority list runs after those that are, in the order they were registered.

This matters most when you have middleware with dependencies on each other. Authenticate needs the session to be started before it can check the user. StartSession needs cookies to be decrypted first. The priority list enforces those dependencies explicitly.


The Common Exam Traps

A few scenarios come up repeatedly in the question bank that are worth having sharp.

Forgetting to return from handle. If your handle method does not return a response, you will get a null response back through the pipeline and a runtime error. The exam will show you middleware with a missing return and ask what happens. The answer is the pipeline breaks.

      // Broken - missing return
public function handle(Request $request, Closure $next): Response
{
    $response = $next($request);
    $response->header('X-Custom', 'MyValue');
    // No return!
}

    

The short-circuit propagates upward. When middleware returns early without calling $next, not only does the rest of the inbound pipeline not run, but the outbound path for preceding middleware does run. Middleware that ran before the short-circuit will still process the response on the way back. Only the ones after the short-circuit get skipped entirely.

Dynamic middleware registration edge cases. Applying middleware dynamically at runtime using a variable can work, but the class must be resolvable from the container. If the class does not exist or has unresolvable dependencies, you will get a binding resolution exception at dispatch time, not at registration time.

Global middleware in Laravel 12. The question bank includes questions specifically about when global middleware runs in the Laravel 12 bootstrap approach. Global middleware runs before route matching, which means it fires even for requests that will ultimately 404. Keep that in mind when writing middleware that should only run for valid routes.


Why This Matters for Your Certification

Middleware accounts for a significant chunk of the mid-level and senior question bank, and the questions are almost entirely focused on behaviour rather than syntax. Can you trace what happens to a request when middleware short-circuits? Do you know what terminable middleware is for and the gotcha with instance state? Can you explain why middleware ordering matters and how to control it?

The pipeline mental model is the thing to lock in. Once you see the stack as concentric layers rather than a linear sequence, the behaviour of short-circuiting, outbound processing, and prioritisation all follow naturally. Every weird edge case in the exam questions is a consequence of how the pipeline works, not a random quirk to memorise.

The next article moves into the intermediate tier of the series with a deep dive on events and listeners: how the event system dispatches, how listeners are discovered, and what actually happens when you call Event::fake() in a test.

More certificates.dev articles

Get the latest news and updates on developer certifications. Content is updated regularly, so please make sure to bookmark this page or sign up to get the latest content directly in your inbox.