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;
}
}
Source: Laravel — Defining Middleware and Middleware Parameters
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();
Source: Laravel — Registering Middleware
| Method | Effect |
|---|---|
|
Add to the end or start of the global stack, which runs on every request, before routing. |
|
Take a default global middleware out, swap it, or redefine the whole global stack. |
|
A short name usable in routes, with parameters: |
|
Build or change a named group; |
|
The order in which route middleware runs, whatever order a route lists them in. |
|
Configure a built-in middleware instead of subclassing it. |
|
Where |
|
Sanctum SPA cookies on the |
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
Source: Laravel — Excluding Middleware and Middleware Groups
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
Source: Laravel — Listing Your Routes
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 |
|
|
|
|
|
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
Source: Laravel — Rate Limiting
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,
);
Source: Laravel — Sorting Middleware
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}
Source: Laravel — Terminable Middleware
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()];
}
}
Source: Laravel — Controller Middleware
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
Source: Laravel — Testing and Pest — Writing Tests
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.phpwith$middleware,$middlewareGroupsand$routeMiddleware(Stauffer ch. 10) is gone from new applications since Laravel 11; the same configuration is thewithMiddleware()closure inbootstrap/app.php, and the default stacks live in the framework. -
Publishing
TrustProxiesandTrustHostsintoapp/Http/Middlewareand editing their properties (Stauffer ch. 10) is replaced bytrustProxies()andtrustHosts(); thefideloper/proxypackage is not needed. -
VerifyCsrfTokenwith an$exceptarray isPreventRequestForgeryin Laravel 13, configured withpreventRequestForgery(except: [...])(orvalidateCsrfTokens()); 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) orHasMiddleware(Laravel 11).