When an Inertia SSR node process dies and your Laravel site still returns HTTP 200 with an empty shell, the failure hides in plain sight — exactly the kind of gap that disciplined laravel middleware design closes. Middleware is the choke point through which every HTTP request and response passes, which makes it the right place to enforce auth, mutate input, throttle traffic, and detect degraded subsystems. This guide covers how the global middleware stack, middleware groups, route aliases, and terminable middleware actually execute, with code you can drop into a production app today.
Middleware runs in a layered pipeline: global middleware processes every request, group middleware applies per web or api context, and route aliases apply per route. Master the ordering rules and the terminate() lifecycle, and you can intercept both requests and responses — including failures that still return 200.
What Is Laravel Middleware and How Does the Pipeline Work?
Laravel middleware are classes that wrap the HTTP layer of your application. Each middleware receives the incoming request and a $next closure that represents the rest of the application — the remaining middleware layers and, ultimately, your controller or closure. This structure is a classic onion (or layered) architecture: the first middleware in the stack is the outermost shell, and each subsequent layer nests inside it.
The pipeline itself lives in Illuminate\Pipeline\Pipeline, which resolves each middleware through the service container. That means middleware constructors support full dependency injection — you can inject repositories, config values, or services without any manual wiring. The dispatcher assembles the final stack from three sources, in this order:
- The global middleware stack in
bootstrap/app.php(Laravel 11+) orapp/Http/Kernel.php(Laravel 10 and earlier). - Middleware groups —
webandapiby default — attached to routes viaRoute::middleware(). - Route middleware aliases assigned to individual routes or route groups.
Understanding this assembly order matters because middleware ordering is not cosmetic. In systems I've tuned, misordered middleware caused subtle bugs: a CORS middleware placed after authentication rejects preflight requests before the auth layer can short-circuit them; a session middleware placed after rate limiting means throttling counts unauthenticated probes against the same global bucket.
<?php
// bootstrap/app.php (Laravel 11+)
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
// Append to the global middleware stack
$middleware->append(\App\Http\Middleware\LogRequestDuration::class);
// Prepend — runs before everything else
$middleware->prepend(\App\Http\Middleware\TrustProxies::class);
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();How Do Global Middleware, Groups, and Route Aliases Differ?
The three registration tiers solve different scoping problems, and choosing the wrong tier is one of the most common laravel middleware mistakes. The table below summarizes when to use each.
| Tier | Scope | Registered In | Typical Use |
|---|---|---|---|
| Global middleware stack | Every HTTP request | bootstrap/app.php or Http/Kernel.php $middleware | TrustProxies, CORS, request logging, maintenance mode |
| Middleware groups | All routes in a group | $middlewareGroups['web'] / ['api'] | Session handling, CSRF, rate limiting per context |
| Route middleware aliases | Named routes individually | $middleware->alias() or $routeMiddleware | auth, throttle, can, custom guards |
Middleware groups exist because web pages and JSON APIs need fundamentally different request handling. The web group encrypts cookies, starts sessions, and validates CSRF tokens; the api group throttles by IP and binds route model resolution differently. You can define your own groups — a dashboard group that combines web plus an IP allowlist, for instance — and Laravel's default groups never run automatically; they apply only when a route declares them.
Route middleware aliases are the per-route layer. Laravel ships aliases like auth, guest, signed, and throttle, and you register custom ones with a short key so route files stay readable.
<?php
// bootstrap/app.php — registering route middleware aliases
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'team.context' => \App\Http\Middleware\EnsureTeamContext::class,
'feature.flag' => \App\Http\Middleware\CheckFeatureFlag::class,
'ssr.health' => \App\Http\Middleware\EnsureSsrHealthy::class,
]);
})
// routes/web.php
Route::middleware(['auth', 'team.context:slug'])
->prefix('teams/{team}')
->group(function () {
Route::get('/projects', [ProjectController::class, 'index'])
->middleware('feature.flag:projects-v2');
});Note the parameter syntax: team.context:slug passes slug as the $parameters argument to the middleware's handle() method after the request. Multiple parameters are comma-separated: feature.flag:projects-v2,read.
How Do You Write Custom Middleware That Handles Real-World Failures?
Consider the failure mode from that Inertia SSR incident: the Node SSR process crashes, but the Laravel app keeps serving HTTP 200 with client-side-only markup. Users see a blank page; monitoring sees success. A middleware that checks SSR health per request — with a cached result to avoid hammering the Node server — turns a silent outage into a manageable degradation strategy.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
class EnsureSsrHealthy
{
public function handle(Request $request, Closure $next): mixed
{
$healthy = Cache::remember('ssr_health', 30, function () {
try {
return Http::timeout(2)
->get(config('inertia.ssr_url', 'http://127.0.0.1:13714/health'))
->ok();
} catch (\Throwable) {
return false;
}
});
if (! $healthy) {
// Flag the response so the client falls back to client-side rendering
$request->attributes->set('ssr_degraded', true);
}
return $next($request);
}
}The pattern generalizes: short-timeout health probes, a cache TTL that bounds probe frequency, and a graceful fallback rather than a hard failure. This pairs naturally with the caching strategies covered in Laravel Caching Strategies That Cut DB Load — the same remember-with-TTL discipline applies.
Middleware should decide whether a request proceeds, not how business logic executes. If you find yourself querying multiple tables or calling payment APIs inside handle(), extract that work into a class bound in the service container and inject it instead — middleware stays thin, testable, and reusable across routes.
What Is Terminable Middleware and When Should You Use It?
Most middleware run before the response is sent. Terminable middleware adds a second hook: terminate(), invoked after the response has been flushed to the browser (when running under FPM, or via Octane's request lifecycle). This is where you put work the user should never wait for — request logging, metrics emission, slow-query tracking.
<?php
class LogSlowRequests
{
public function handle(Request $request, Closure $next): mixed
{
$request->attributes->set('_start', hrtime(true));
return $next($request);
}
public function terminate(Request $request, \Illuminate\Http\Response $response): void
{
$ms = (hrtime(true) - $request->attributes->get('_start')) / 1e6;
if ($ms >= 500) {
Log::warning('slow_request', [
'path' => $request->path(),
'ms' => round($ms),
'status' => $response->getStatusCode(),
]);
}
}
}Two constraints apply. First, the middleware must be registered in the global stack or a group — terminable middleware on a route alias still works, but the terminate hook only fires if the middleware was resolved through the standard pipeline. Second, terminate() receives the original request, not any mutations made deeper in the stack, so persist any state you need onto request attributes during handle().
Which Middleware Ordering Pitfalls Break Authentication and CORS?
Ordering bugs are silent because each middleware works correctly in isolation. Three concrete traps recur in code reviews:
- CORS before session/auth. Preflight
OPTIONSrequests carry no credentials. If auth middleware runs first, preflights get 401s and browsers block every cross-origin call. Handle CORS at the global layer. - Rate limiting before authentication when you throttle per-user. Unauthenticated requests share one IP bucket while authenticated users should get per-account limits. Throttle after auth resolves the user.
- Response-mutating middleware placed too early. Middleware that adds security headers must wrap the entire stack, or inner middleware that abort (e.g.,
abort(404)) bypass the headers because the response never travels back through the outer layer.
Laravel 10.15+ provides $middleware->priority() to declare ordering constraints explicitly, so dependencies between middleware are documented rather than implicit in array position:
->withMiddleware(function (Middleware $middleware) {
$middleware->priority([
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\EnsureSsrHealthy::class,
\Illuminate\Auth\Middleware\Authenticate::class,
]);
})When debugging middleware order, add a temporary middleware that logs __CLASS__ on entry and exit. The resulting interleaved log shows exactly which layer aborts a request — far faster than stepping through the kernel with a debugger.
How Should You Test Middleware in Laravel?
Because middleware are plain classes with a single entry point, unit testing them directly is straightforward: instantiate the class, build a request via Request::create(), and pass a closure that returns a marker response. Test both the pass-through path and the abort path.
public function test_blocks_requests_when_ssr_is_down(): void
{
Cache::forget('ssr_health');
Http::fake(['*' => Http::response(null, 503)]);
$middleware = new EnsureSsrHealthy;
$request = Request::create('/dashboard');
$middleware->handle($request, fn () => new Response('ok'));
$this->assertTrue($request->attributes->get('ssr_degraded'));
}For integration coverage, $this->withMiddleware() and withoutMiddleware() in feature tests let you toggle specific layers per test, and Laravel Testing patterns like HTTP fakes keep the suite hermetic. For deeper framework behavior — pipeline resolution, container binding inside middleware — the official Laravel middleware documentation is the authoritative reference, and PHP constructor documentation covers the DI mechanics.
Frequently Asked Questions
What is the difference between middleware and controllers?
Middleware filters requests before they reach routes and can post-process responses. Controllers contain your application's response logic. Middleware handles cross-cutting concerns — auth, throttling, headers — while controllers handle business rules.
Can middleware pass data to controllers?
Yes. Set values on $request->attributes in handle(), then read them via $request->attributes->get() in the controller. Route model binding and resolved route parameters are also available to controllers.
Does middleware run for artisan commands?
No. HTTP middleware only applies to requests routed through the HTTP kernel. Console commands, queued jobs, and scheduled tasks bypass middleware entirely — enforce those concerns in job middleware or command logic instead.
How do I skip middleware for a specific route?
Use Route::withoutMiddleware([Middleware::class]) on the route definition, or exclude it in tests with $this->withoutMiddleware(). Exclusions must reference the exact middleware class or alias.
Middleware is the highest-leverage layer of a Laravel application: one well-placed class can enforce policy across hundreds of routes, and one misordered class can break them all. Audit your global middleware stack, name your route aliases deliberately, and move anything the user shouldn't wait for into terminate(). If your app renders through Inertia SSR, add a health-check middleware before your next deploy — the incident that prompted this article was entirely preventable with thirty lines of code.
