Middleware

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. Every example runs in the Bookshelf application used across this guide. A middleware is a class with a handle(Request $request, Closure $next) method that sits between the router and the controller: it can inspect or reject the request before calling $next($request), and change the response that $next() returns. Where middleware sits in the whole request is shown on Request Lifecycle; the same pattern built by hand with PSR-15 is on Templating, Routing and Middleware by Hand.

Writing middleware

php artisan make:middleware SetLocale creates app/Http/Middleware/SetLocale.php (the directory does not exist in a new application until the first middleware is generated). Code before $next() is "before" middleware, code after it is "after" middleware, and one class can do both. Extra arguments of handle() are parameters, passed after a colon (locale:en,es):

<?php
// app/Http/Middleware/SetLocale.php
namespace App\Http\Middleware;

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

class SetLocale
{
    /** A "before" middleware with parameters: locale:en,es */
    public function handle(Request $request, Closure $next, string ...$supported): Response
    {
        // Best match from Accept-Language; falls back to the first supported locale
        $locale = $request->getPreferredLanguage($supported);
        app()->setLocale($locale);

        $response = $next($request);
        $response->headers->set('Content-Language', $locale);

        return $response;
    }
}
<?php
// app/Http/Middleware/AddSecurityHeaders.php
namespace App\Http\Middleware;

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

class AddSecurityHeaders
{
    /** An "after" middleware: let the request through, then change the response. */
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);

        $response->headers->set('X-Content-Type-Options', 'nosniff');
        $response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
        if (! $response->headers->has('Content-Security-Policy')) {    // keep a page's own policy
            $response->headers->set('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'");
        }

        return $response;
    }
}

The has() check matters for an after middleware that runs globally: without it, the default policy overwrote the nonce-based policy a page set for Vite (Vite and Frontend Assets). To reject a request, return a response instead of calling $next(): return redirect('/login'), abort(403) or response()->json([...], 401). Middleware is resolved from the service container, so its constructor can take dependencies. What the security headers themselves mean is covered on Web Security Fundamentals.

This default policy also forbids eval, and Livewire’s Alpine evaluates wire:click expressions with new Function(). In a browser, Bookshelf’s buttons did nothing until 'csp_safe' => true in config/livewire.php switched to Alpine’s CSP build, which the run on Browser Testing with Dusk shows.

Registering middleware in bootstrap/app.php

There is no app/Http/Kernel.php any more. Everything is configured in the withMiddleware() closure of bootstrap/app.php, on an Illuminate\Foundation\Configuration\Middleware object:

<?php
// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;

return Application::configure(basePath: dirname(__DIR__))
    // ... withRouting() and withExceptions() unchanged ...
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->append(\App\Http\Middleware\AddSecurityHeaders::class);   // global, after the defaults
        $middleware->alias(['locale' => \App\Http\Middleware\SetLocale::class]);
        $middleware->appendToGroup('catalogue', ['locale:en,es', 'throttle:reviews']);
        $middleware->trustProxies(at: ['10.0.0.0/8']);                               // the load balancer's network
        $middleware->trustHosts(at: ['^bookshelf\.test$']);
    })
    ->create();
Method Effect

append() / prepend()

Add to the end or start of the global stack, which runs on every request, before routing.

remove() / replace() / use([...])

Take a default global middleware out, swap it, or redefine the whole global stack.

alias(['name' => Class::class])

A short name usable in routes, with parameters: ->middleware('locale:en,es').

appendToGroup() / prependToGroup() / removeFromGroup() / replaceInGroup()

Build or change a named group; web([...]) and api([...]) are shortcuts for the two default groups (append:, prepend:, remove: and replace: arguments).

priority([...]), prependToPriorityList(), appendToPriorityList()

The order in which route middleware runs, whatever order a route lists them in.

encryptCookies(except:), preventRequestForgery(except:), trimStrings(except:), convertEmptyStringsToNull(except:), validateSignatures(except:)

Configure a built-in middleware instead of subclassing it.

redirectGuestsTo(), redirectUsersTo()

Where auth sends guests and where guest sends logged-in users.

statefulApi(), throttleApi(), throttleWithRedis(), authenticateSessions()

Sanctum SPA cookies on the api group, a throttle:api limit, the Redis-backed throttle, auth.session on web.

Middleware is then attached to routes by class, alias or group name, and groups and aliases can be mixed:

<?php
// routes/catalogue.php (loaded with the web group by withRouting(then: ...))
use App\Http\Controllers\ReviewController;
use App\Http\Middleware\AddSecurityHeaders;
use Illuminate\Support\Facades\Route;

Route::get('/reviews', [ReviewController::class, 'index']);
Route::middleware('catalogue')->get('/hello', fn () => __('Hello') . ' (' . app()->getLocale() . ')');
Route::get('/plain', fn () => 'no security headers here')->withoutMiddleware(AddSecurityHeaders::class);
$ curl -s -o /dev/null -D - -H 'Accept-Language: es-ES,es;q=0.9' localhost:8000/hello | grep -i content-language
Content-Language: es
$ curl -s -o /dev/null -D - -H 'Accept-Language: fr' localhost:8000/hello | grep -i content-language
Content-Language: en
$ curl -sI localhost:8000/plain | grep -i content-type-options
X-Content-Type-Options: nosniff

The last result is the documented rule: withoutMiddleware() removes route middleware only. AddSecurityHeaders was appended to the global stack, so /plain still gets the header. To skip a global middleware for some paths, the middleware itself has to check the request ($request->is('webhooks/*')), or it should be route middleware instead.

php artisan route:list -v shows the middleware that will run for each route, groups expanded one level:

$ php artisan route:list -v --path=reviews

 GET|HEAD reviews .. ReviewController@index
 ⇂ web
 ⇂ locale:en,es
 ⇂ throttle:reviews

The default groups and aliases

withRouting() puts routes/web.php in the web group and routes/api.php (after php artisan install:api) in the api group with a /api prefix. The stacks a new Laravel 13.34 application reports are:

Stack Middleware, in order

Global

ValidatePathEncoding, InvokeDeferredCallbacks, TrustProxies, HandleCors, PreventRequestsDuringMaintenance, ValidatePostSize, TrimStrings, ConvertEmptyStringsToNull (and TrustHosts only when trustHosts() is called)

web

EncryptCookies, AddQueuedCookiesToResponse, StartSession, ShareErrorsFromSession, PreventRequestForgery (CSRF, called VerifyCsrfToken before Laravel 13), SubstituteBindings (route model binding)

api

SubstituteBindings only: no session, cookies or CSRF

The built-in aliases, read from Middleware::defaultAliases(), are auth, auth.basic, auth.session, cache.headers, can, guest, password.confirm, precognitive, signed, throttle and verified. They are used on Authentication, Authorization and Routing (signed, throttle).

Parameters, priority and shared limits

Parameters are strings separated by commas after the colon; a variadic string ...$supported collects them all. The throttle:reviews limiter on /reviews and on the catalogue group of /hello shows a consequence of how limits are keyed:

$ for i in 1 2 3 4; do curl -s -o /dev/null -w '%{http_code} ' localhost:8000/reviews; done; echo
200 200 200 429
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/hello
429

The reviews limiter is keyed by user or IP only, so every route that uses it shares one counter: three requests to /reviews used up the budget of /hello too. Add the route to the key (->by($request->route()->getName() . '|' . $request->ip())) or define one limiter per purpose.

When a route lists several middleware, Laravel sorts them by a priority list (session before authentication, authentication before authorization, SubstituteBindings before can: so the policy receives a model). A custom middleware that must run, for example, before route model binding is inserted relative to an existing entry:

<?php
// bootstrap/app.php, inside withMiddleware()
$middleware->prependToPriorityList(
    before: \Illuminate\Routing\Middleware\SubstituteBindings::class,
    prepend: \App\Http\Middleware\SetLocale::class,
);

Terminable middleware

A terminate(Request $request, Response $response) method runs after the response has been sent to the browser when PHP runs under FastCGI (PHP-FPM), which flushes the response first. It is the place for logging and metrics that should not delay the user:

<?php
// app/Http/Middleware/LogRequestDuration.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

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

    /** Runs after the response has been sent (under PHP-FPM). */
    public function terminate(Request $request, Response $response): void
    {
        $ms = round((microtime(true) - LARAVEL_START) * 1000, 1);

        Log::info('request', ['path' => $request->path(), 'status' => $response->getStatusCode(), 'ms' => $ms]);
    }
}
$ curl -s localhost:8000/timed
timed
$ tail -1 storage/logs/laravel.log
[2026-10-05 19:55:00] local.INFO: request {"path":"timed","status":200,"ms":18.0}

terminate() is called on a fresh instance resolved from the container, so state set in handle() is lost unless the middleware is registered as a singleton ($this->app->singleton(LogRequestDuration::class)). For one-off work after the response, defer() is simpler; both are traced in order on Request Lifecycle.

The #[Middleware] attribute

Laravel 13 lets a controller declare its middleware with the Illuminate\Routing\Attributes\Controllers\Middleware attribute, on the class (for every action, with only: and except:) or on a method; method attributes are merged with the class’s:

<?php
// app/Http/Controllers/ReviewController.php
namespace App\Http\Controllers;

use Illuminate\Routing\Attributes\Controllers\Middleware;

#[Middleware('locale:en,es')]
class ReviewController
{
    #[Middleware('throttle:reviews')]
    public function index(): array
    {
        return ['locale' => app()->getLocale()];
    }
}

The route:list -v output above shows web, then the class attribute, then the method attribute. The older HasMiddleware interface with a static middleware() method (Laravel 11) still works; both are compared on Controllers.

Trusted proxies and trusted hosts

Behind a load balancer every request comes from the balancer’s address over plain HTTP, and the client’s real address and scheme arrive in X-Forwarded-* headers. Those headers are only believed when the request comes from a proxy listed in trustProxies(at:), because anyone can send them. The /ip route returns $request->ip() and $request->getScheme(), requested from 127.0.0.1 with forged headers:

trustProxies(at: ['10.0.0.0/8'])   -> {"ip":"127.0.0.1","scheme":"http"}       headers ignored
trustProxies(at: ['127.0.0.1'])    -> {"ip":"203.0.113.9","scheme":"https"}    headers believed

trustProxies(at: '*') trusts every address and is only safe when the application cannot be reached except through the proxy. Getting this wrong breaks HTTPS URL generation and secure cookies (too few trusted) or lets clients spoof their IP past rate limits and logs (too many).

trustHosts() enables TrustHosts, which rejects requests whose Host header the application does not serve. This prevents host-header attacks such as poisoned password-reset links. Each entry is a regular expression, and the host of APP_URL with all its subdomains is added unless subdomains: false is passed. Tested with APP_ENV=production and APP_URL=http://localhost:8000:

trustHosts(at: ['bookshelf.test', 'localhost'])     trustHosts(at: ['^bookshelf\.test$'])
Host: evil.example             400                  Host: evil.example             400
Host: localhost.evil.example   200                  Host: localhost.evil.example   400
Host: bookshelfXtest           200                  Host: bookshelfXtest           400
Host: bookshelf.test           200                  Host: bookshelf.test           200
                                                    Host: api.localhost            200  (APP_URL subdomain)

Unanchored patterns match anywhere in the host, and an unescaped . matches any character, so write ^…$ with \.. TrustHosts also does nothing when APP_ENV=local or while running tests (its shouldSpecifyTrustedHosts() returns false), so an evil Host header gets 200 on a development machine. Test it with the production environment, as above. Web servers can do the same check earlier (an Nginx server_name with a default server that returns 444).

Testing middleware

Feature tests go through the full stack, so middleware runs as in production. withoutMiddleware() in a test skips the named middleware, or all middleware when called without arguments:

<?php
// tests/Feature/MiddlewareTest.php
use App\Http\Middleware\AddSecurityHeaders;

it('negotiates the locale from Accept-Language', function () {
    $this->get('/hello', ['Accept-Language' => 'es-ES,es;q=0.9'])
        ->assertOk()
        ->assertHeader('Content-Language', 'es');

    $this->get('/hello', ['Accept-Language' => 'fr'])
        ->assertHeader('Content-Language', 'en');   // first supported locale
});

it('adds the security headers to every response', function () {
    $this->get('/up')->assertHeader('X-Content-Type-Options', 'nosniff');
});

it('can skip a middleware in a test', function () {
    $this->withoutMiddleware(AddSecurityHeaders::class)
        ->get('/up')
        ->assertHeaderMissing('X-Content-Type-Options');
});
$ php artisan test --filter=Middleware
   PASS  Tests\Feature\MiddlewareTest
  ✓ it negotiates the locale from Accept-Language                        0.11s
  ✓ it adds the security headers to every response                       0.01s
  ✓ it can skip a middleware in a test                                   0.01s

  Tests:    3 passed (8 assertions)
  Duration: 0.18s

Unlike route definitions, the test helper can skip global middleware. Testing in general is on Testing.

Where older books and tutorials are outdated

  • app/Http/Kernel.php with $middleware, $middlewareGroups and $routeMiddleware (Stauffer ch. 10) is gone from new applications since Laravel 11; the same configuration is the withMiddleware() closure in bootstrap/app.php, and the default stacks live in the framework.

  • Publishing TrustProxies and TrustHosts into app/Http/Middleware and editing their properties (Stauffer ch. 10) is replaced by trustProxies() and trustHosts(); the fideloper/proxy package is not needed.

  • VerifyCsrfToken with an $except array is PreventRequestForgery in Laravel 13, configured with preventRequestForgery(except: [...]) (or validateCsrfTokens()); the old names remain as deprecated aliases.

  • $this->middleware('auth') in a controller constructor (Stauffer ch. 3) no longer works on the base controller of new applications; use the #[Middleware] attribute (Laravel 13) or HasMiddleware (Laravel 11).