What Middleware Is

Middleware is a class that sits between the incoming HTTP request and your route handler. Every request passes through a stack of them in order. Each one can inspect the request, modify it, pass it along — or stop it dead and return a response of its own.

Think of it as airport security for your application. The passenger (request) walks through a series of checkpoints. Any checkpoint can wave them through, tag their luggage, or turn them away before they ever reach the gate (your controller).

The pattern has a name: the pipeline (or chain of responsibility). Laravel implements it with Illuminate\Pipeline\Pipeline, and the same mechanism powers job middleware and Eloquent’s query pipeline.

The Request Pipeline

The important detail is that middleware wraps your controller in both directions. Code before $next($request) runs on the way in; code after it runs on the way out, once a response exists.

Request
  |
  v
[ TrustProxies ]        --> before
[ HandleCors ]          --> before
[ ValidatePostSize ]    --> before
[ EncryptCookies ]      --> before
[ VerifyCsrfToken ]     --> before
        |
        v
    Controller / Route closure
        |
        v
[ VerifyCsrfToken ]     <-- after
[ EncryptCookies ]      <-- after (encrypts outgoing cookies)
[ HandleCors ]          <-- after (adds CORS headers)
        |
        v
Response

Notice that middleware unwinds in reverse order. The first middleware in is the last one out — which matters when one middleware depends on something another one set up.

Creating Your Own

php artisan make:middleware EnsureUserIsSubscribed

That scaffolds a class with a single handle() method:

<?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
    {
        if (! $request->user()?->subscribed()) {
            return redirect()->route('billing')
                ->with('error', 'A subscription is required to view that page.');
        }

        return $next($request);
    }
}

Two outcomes, and that is the whole contract:

  • Return $next($request) — the request continues down the pipeline.
  • Return anything else — a redirect, a JSON response, an abort — and the pipeline short-circuits. The controller never runs.

Middleware is resolved out of the service container, so you can type-hint dependencies in the constructor and Laravel will inject them.

Registering It

This is the part that changed. Laravel 10 and earlier used app/Http/Kernel.php. Laravel 11 removed that file and moved registration into bootstrap/app.php.

Laravel 11 and 12:

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    // Give it a short name for use on routes
    $middleware->alias([
        'subscribed' => \App\Http\Middleware\EnsureUserIsSubscribed::class,
    ]);

    // Run on every web request
    $middleware->web(append: [
        \App\Http\Middleware\TrackPageViews::class,
    ]);

    // Run on every API request
    $middleware->api(prepend: [
        \App\Http\Middleware\ForceJsonResponse::class,
    ]);

    // Run on absolutely every request
    $middleware->append(\App\Http\Middleware\SecurityHeaders::class);
})

Laravel 10 and earlier:

// app/Http/Kernel.php
protected $middlewareAliases = [
    'auth'       => \App\Http\Middleware\Authenticate::class,
    'subscribed' => \App\Http\Middleware\EnsureUserIsSubscribed::class,
];

protected $middlewareGroups = [
    'web' => [
        \App\Http\Middleware\EncryptCookies::class,
        // ...
        \App\Http\Middleware\TrackPageViews::class,
    ],
];

Applying it to routes is the same on every version:

Route::get('/premium', PremiumController::class)
    ->middleware('subscribed');

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

// Or in a controller constructor (Laravel 10 and earlier)
public function __construct()
{
    $this->middleware('subscribed')->only(['edit', 'update']);
    $this->middleware('auth')->except('index');
}

Laravel 11 introduced a cleaner controller option via the HasMiddleware interface:

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

class ReportController extends Controller implements HasMiddleware
{
    public static function middleware(): array
    {
        return [
            'auth',
            new Middleware('subscribed', only: ['export']),
        ];
    }
}

Before vs After Middleware

Where you put your logic relative to $next() decides when it runs.

// BEFORE — runs on the way in, can block the request
public function handle(Request $request, Closure $next): Response
{
    if ($request->ip() === '10.0.0.1') {
        abort(403);
    }

    return $next($request);
}

// AFTER — runs on the way out, can modify the response
public function handle(Request $request, Closure $next): Response
{
    $response = $next($request);

    $response->headers->set('X-Frame-Options', 'DENY');
    $response->headers->set('X-Content-Type-Options', 'nosniff');

    return $response;
}

// BOTH — time the request
public function handle(Request $request, Closure $next): Response
{
    $start = microtime(true);

    $response = $next($request);

    $ms = round((microtime(true) - $start) * 1000);
    $response->headers->set('X-Response-Time', $ms . 'ms');

    return $response;
}

There is a third hook: terminate(). It runs after the response has been sent to the browser, which makes it the right place for slow bookkeeping you do not want the user waiting on.

public function terminate(Request $request, Response $response): void
{
    // Runs after the response is sent — the user is not waiting
    AuditLog::create([
        'user_id' => $request->user()?->id,
        'path'    => $request->path(),
        'status'  => $response->getStatusCode(),
    ]);
}

terminate() only fires with a FastCGI-style server. Under Octane, Swoole or RoadRunner the lifecycle differs — check the Octane docs before relying on it there.

Middleware Parameters

Extra arguments after the middleware name are passed to handle(), separated by colons. This is how throttle:60,1 and can:update,post work.

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

        return $next($request);
    }
}
Route::get('/admin', AdminController::class)
    ->middleware('role:admin');

// Multiple values, comma separated
Route::get('/reports', ReportController::class)
    ->middleware('role:admin,manager');

Groups, Aliases and Ordering

Three concepts that are easy to confuse:

ConceptWhat it doesExample
GlobalRuns on every single requestTrustProxies, HandleCors
GroupA named bundle applied to many routesweb, api
AliasA short name for one middleware classauth, throttle

Order matters when middleware depends on each other — authentication must run before an authorisation check that reads $request->user(). Laravel sorts a known list automatically, and you can extend it:

// bootstrap/app.php (Laravel 11/12)
$middleware->priority([
    \Illuminate\Cookie\Middleware\EncryptCookies::class,
    \Illuminate\Session\Middleware\StartSession::class,
    \Illuminate\Auth\Middleware\Authenticate::class,
    \App\Http\Middleware\EnsureUserIsSubscribed::class,
]);

You can also strip a middleware off specific routes:

Route::post('/webhooks/stripe', StripeWebhookController::class)
    ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]);

Middleware Laravel Ships With

AliasPurpose
authRequires an authenticated user; redirects guests to login
auth:sanctumAuthenticates API tokens or SPA session cookies
guestThe opposite — blocks users who are already logged in
verifiedRequires a verified email address
password.confirmForces a recent password confirmation
throttle:60,1Rate limit — 60 requests per minute
can:update,postRuns an authorisation policy check
signedValidates a signed URL signature

Common Mistakes

  • Forgetting to return $next($request). The request silently dies and the browser gets a blank response. If a route mysteriously returns nothing, check the middleware first.
  • Putting business logic in middleware. Middleware should decide whether a request may proceed. Creating records, sending mail and calculating totals belong in the controller or an action class.
  • Heavy queries in global middleware. Anything you register globally runs on every asset request, health check and webhook. Scope it to a group instead.
  • Assuming the session exists. Session data is only available after StartSession has run — so a global middleware registered before it cannot read session().
  • Excluding CSRF the wrong way. Webhook endpoints belong on the api routes file, or in the CSRF exclusion list — not behind a disabled CSRF middleware application-wide.

Run php artisan route:list to see exactly which middleware applies to each route. It is the fastest way to debug “why is this endpoint returning a redirect?”