DEV Community

Cover image for Laravel Middleware: Route-Level, Groups & Ordering Pitfalls
devTalk
devTalk

Posted on Originally published at dev-talk.com

Laravel Middleware: Route-Level, Groups & Ordering Pitfalls

Laravel middleware is where most "it works locally but not in production" routing bugs hide: a route that should require login but doesn't, a rate limit that counts the wrong user, a model that loads before the permission check that was supposed to guard it. Part 1 showed that web.php and api.php differ because of the middleware group wrapped around them, and Part 2 covered route model binding. This part covers how middleware is registered, how it is ordered, and the mistakes that come from getting either one wrong.

What middleware actually does

Middleware is a layer a request passes through on the way to your controller, and the response passes back through on the way out. Each layer can inspect the request, stop it early, or modify the response:

php artisan make:middleware EnsureUserIsSubscribed
Enter fullscreen mode Exit fullscreen mode
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class EnsureUserIsSubscribed
{
    public function handle(Request $request, Closure $next): Response
    {
        // "Before" logic: runs on the way in.
        if (! $request->user()?->isSubscribed()) {
            return redirect()->route('billing');
        }

        $response = $next($request);

        // "After" logic: runs on the way out, with the response in hand.
        return $response;
    }
}
Enter fullscreen mode Exit fullscreen mode

Returning a response without calling $next($request) short-circuits the request: nothing after this layer runs, including your controller. Calling $next passes control to the next layer.

Where middleware is registered in Laravel 11+

As of Laravel 11, the app/Http/Kernel.php file is gone. Middleware configuration lives in bootstrap/app.php, inside withMiddleware(). Older tutorials that tell you to edit $routeMiddleware in the Kernel apply to Laravel 10 and earlier only.

// bootstrap/app.php
use App\Http\Middleware\EnsureUserIsSubscribed;
use Illuminate\Foundation\Configuration\Middleware;

->withMiddleware(function (Middleware $middleware) {
    // Run on every request.
    $middleware->append(App\Http\Middleware\LogRequest::class);

    // Give a middleware a short name for use on routes.
    $middleware->alias([
        'subscribed' => EnsureUserIsSubscribed::class,
    ]);
})
Enter fullscreen mode Exit fullscreen mode

Once aliased, attach it to a route by name:

Route::get('/dashboard', [DashboardController::class, 'index'])
    ->middleware('subscribed');
Enter fullscreen mode Exit fullscreen mode

Route-level vs. group-level middleware

Attach middleware to one route, or to many at once with a group:

// One route:
Route::get('/profile', [ProfileController::class, 'show'])->middleware('auth');

// Several routes sharing the same middleware:
Route::middleware(['auth', 'subscribed'])->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index']);
    Route::get('/reports', [ReportController::class, 'index']);
});
Enter fullscreen mode Exit fullscreen mode

You can also define your own named group in bootstrap/app.php and reuse it across route files:

->withMiddleware(function (Middleware $middleware) {
    $middleware->appendToGroup('members', [
        'auth',
        EnsureUserIsSubscribed::class,
    ]);
})
Enter fullscreen mode Exit fullscreen mode
Route::middleware('members')->group(function () {
    // ...
});
Enter fullscreen mode Exit fullscreen mode

To add middleware to the built-in web or api groups from Part 1 without replacing what's already there, use $middleware->web(append: [...]) or $middleware->api(prepend: [...]).

Applying middleware inside a controller

If you'd rather keep the rule next to the code it protects, a controller can declare its own middleware by implementing HasMiddleware:

use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;

class PostController extends Controller implements HasMiddleware
{
    public static function middleware(): array
    {
        return [
            'auth',
            new Middleware('subscribed', only: ['store', 'update']),
        ];
    }
}
Enter fullscreen mode Exit fullscreen mode

Excluding middleware with withoutMiddleware

When a group applies middleware to a whole set of routes and one route is an exception, remove it from that single route:

Route::middleware(['auth'])->group(function () {
    Route::get('/profile', [ProfileController::class, 'show']);

    Route::get('/webhook-status', [WebhookController::class, 'status'])
        ->withoutMiddleware(['auth']);
});
Enter fullscreen mode Exit fullscreen mode

One limit worth knowing: withoutMiddleware can only remove route middleware. It does not remove global middleware, so you can't use it to opt a route out of something you registered with $middleware->append().

Order matters: the bug that doesn't throw an error

Middleware runs in the order it's listed, and the response travels back through the same layers in reverse. That makes order a correctness question, not a style one:

// Rejects unauthenticated users first, then checks subscription.
Route::middleware(['auth', 'subscribed'])->group(/* ... */);

// Wrong order: 'subscribed' runs first and calls $request->user()
// on a request that may not be authenticated yet.
Route::middleware(['subscribed', 'auth'])->group(/* ... */);
Enter fullscreen mode Exit fullscreen mode

Laravel softens this for its own built-in middleware through a priority list. Some middleware must always run before others no matter how you attach them: the session has to start before authentication can read it, and authentication should run before route model binding, so an anonymous visitor can't use a 404 versus a redirect to find out which records exist. When you add your own middleware that has to sit at a specific point relative to those, set the order explicitly in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware) {
    $middleware->priority([
        \Illuminate\Cookie\Middleware\EncryptCookies::class,
        \Illuminate\Session\Middleware\StartSession::class,
        \Illuminate\Contracts\Auth\Middleware\AuthenticatesRequests::class,
        \App\Http\Middleware\EnsureUserIsSubscribed::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ]);
})
Enter fullscreen mode Exit fullscreen mode

Before overriding this, print Laravel's current default priority list and copy from it. The list you pass to priority() replaces the default one, so leaving out a built-in entry changes its position too.

This also explains why per-user rate limiting works at all: the default priority runs authentication before throttling, so by the time the limiter runs, it already knows who the user is. Part 6 covers throttling in detail.

Terminable middleware: work after the response is sent

If a middleware defines a terminate() method, Laravel calls it after the response has been sent to the browser. It's the right place for slow work the user shouldn't wait on, such as writing a detailed audit log:

public function terminate(Request $request, Response $response): void
{
    // Runs after the response has been sent.
    AuditLog::record($request, $response->getStatusCode());
}
Enter fullscreen mode Exit fullscreen mode

Two details catch people out. Laravel creates a fresh instance of the middleware to call terminate(), so any property you set inside handle() is gone. If you need to share state between the two, register the middleware as a singleton in your AppServiceProvider::register() method. And the "after the response is sent" behavior depends on your server setup, so don't rely on it for work that must never delay a response unless you've confirmed it in your environment.

Quick reference

Need Use
Run on every request $middleware->append() / prepend() in bootstrap/app.php
Give a middleware a short name $middleware->alias([...])
Apply to one route ->middleware('name')
Apply to many routes Route::middleware([...])->group(...)
Reusable named bundle $middleware->appendToGroup('name', [...])
Add to the built-in web or api group $middleware->web(append: [...]) / api(...)
Declare inside a controller Implement HasMiddleware
Opt one route out of a group ->withoutMiddleware([...]) (route middleware only)
Force a specific relative order $middleware->priority([...])
Work after the response is sent A terminate() method on the middleware

Before you ship it

Run php artisan route:list -v and read the middleware listed for every sensitive route, rather than trusting what you remember attaching. Put auth before anything that reads the current user. If you exclude middleware from one route in a group, confirm that route genuinely needs to be public. And if you're on an older tutorial that mentions Kernel.php, translate it to bootstrap/app.php before pasting anything.

Related reading: this series starts with Part 1: Laravel Routing Basics and continues with Part 2: Laravel Route Model Binding.


Originally published on DEV Talk.

Top comments (0)