Laravel Middleware Reference Guide

A Laravel middleware reference guide: filtering and inspecting HTTP requests before they reach a route or controller.

What middleware does

Middleware sits between the incoming request and your route/controller, able to inspect, reject, or modify the request on the way in and the response on the way out. This site uses several already: guest and auth gate the login/dashboard routes, and throttle rate-limits login attempts.

Think of a stack of middleware as concentric layers around the actual route handler: the request passes inward through each one in order, and the response passes back outward through the same layers in reverse. That's what makes middleware a natural place for cross-cutting concerns — authentication, CORS headers, request logging, maintenance mode — that would otherwise need to be repeated at the top of every controller method. Laravel ships with several built in (auth, guest, throttle, verified, signed among them), and custom middleware follows the exact same contract.

Creating middleware

php artisan make:middleware EnsureUserIsSubscribed
class EnsureUserIsSubscribed
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! $request->user()?->subscribed()) {
            return redirect()->route('billing');
        }

        return $next($request);
    }
}

Everything before $next($request) runs on the way in; anything after it runs on the way out, once the route/controller has produced a response — useful for logging or modifying the outgoing response. Skipping the call to $next($request) entirely — returning a redirect or an error response instead — is how middleware blocks a request from reaching the route at all, which is exactly what auth does when there's no authenticated user.

Registering middleware

Since Laravel 11, middleware is registered in bootstrap/app.php instead of an app/Http/Kernel.php file.

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->alias([
        'subscribed' => EnsureUserIsSubscribed::class,
    ]);

    // Append to every web request
    $middleware->web(append: [
        EnsureUserIsSubscribed::class,
    ]);
})

Aliasing a middleware class to a short string like 'subscribed' is what lets it be referenced by name on individual routes, as in the next section, rather than always needing the fully-qualified class name. Global middleware — registered via $middleware->web() or ->api() without a route referencing it — runs on every matching request automatically, which is the right place for things like trimming request strings or converting empty strings to null, but the wrong place for anything that should only apply to specific routes.

Applying middleware to routes

Route::get('/dashboard', [DashboardController::class, 'index'])
    ->middleware('auth');

Route::middleware(['auth', 'subscribed'])->group(function () {
    Route::get('/reports', [ReportController::class, 'index']);
    Route::get('/exports', [ExportController::class, 'index']);
});

// Passing parameters to middleware
Route::get('/posts/{post}', [PostController::class, 'show'])
    ->middleware('role:editor');

When several middleware are chained on a group, they run in the order they're listed on the way in and unwind in reverse on the way out — so ->middleware(['auth', 'subscribed']) checks authentication before checking the subscription, which matters if subscribed assumes $request->user() is already populated.

Middleware with parameters

class EnsureUserHasRole
{
    public function handle(Request $request, Closure $next, string $role): Response
    {
        if (! $request->user()?->hasRole($role)) {
            abort(403);
        }

        return $next($request);
    }
}

Terminable middleware

Runs after the response has already been sent to the browser — useful for work that shouldn't delay the response, like logging.

class LogRequestDuration
{
    public function handle(Request $request, Closure $next): Response
    {
        return $next($request);
    }

    public function terminate(Request $request, Response $response): void
    {
        Log::info('Request completed', ['path' => $request->path()]);
    }
}

For this to work with FastCGI (PHP-FPM, which most production setups use), the class must be registered as a global middleware rather than a route-specific one, because Laravel calls terminate() on every terminable middleware instance in the container after fastcgi_finish_request() has flushed the response — a route-only instance may never be resolved in time to have terminate() called on it.

Middleware priority

Route and group middleware normally run in the order they're attached, but some middleware needs to run before others regardless of registration order — auth has to run before anything that reads $request->user(), for instance, even if the route lists them the other way round. The global priority list in bootstrap/app.php controls this:

->withMiddleware(function (Middleware $middleware) {
    $middleware->priority([
        \Illuminate\Auth\Middleware\Authenticate::class,
        \Illuminate\Session\Middleware\AuthenticateSession::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ]);
})

Only middleware appearing in this list is reordered; anything not listed keeps its original registration order relative to the others. It's rarely necessary to touch for application-level middleware, but worth knowing about when a custom middleware seems to run before a dependency it expects to already be resolved.

Testing middleware

Feature tests exercise middleware automatically since they go through the full HTTP kernel, so asserting that an unauthenticated request to a protected route redirects to the login page is usually enough:

public function test_guests_are_redirected_to_login(): void
{
    $this->get('/dashboard')->assertRedirect('/login');
}

When a test needs to bypass middleware entirely — to isolate a controller's own logic from, say, a third-party throttling package — withoutMiddleware() (globally) or withoutMiddleware(SpecificMiddleware::class) (for one class) skips it for that test only, without changing the production registration.