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
ResponseNotice 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 EnsureUserIsSubscribedThat 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:
| Concept | What it does | Example |
|---|---|---|
| Global | Runs on every single request | TrustProxies, HandleCors |
| Group | A named bundle applied to many routes | web, api |
| Alias | A short name for one middleware class | auth, 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
| Alias | Purpose |
|---|---|
auth | Requires an authenticated user; redirects guests to login |
auth:sanctum | Authenticates API tokens or SPA session cookies |
guest | The opposite — blocks users who are already logged in |
verified | Requires a verified email address |
password.confirm | Forces a recent password confirmation |
throttle:60,1 | Rate limit — 60 requests per minute |
can:update,post | Runs an authorisation policy check |
signed | Validates 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
StartSessionhas run — so a global middleware registered before it cannot readsession(). - Excluding CSRF the wrong way. Webhook endpoints belong on the
apiroutes 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?”