Laravel’s middleware system is one of the most elegant aspects of the framework — a clean, layered mechanism that sits between an incoming HTTP request and your application logic. Understanding how middleware works, and how requests flow through it, is essential for building secure, maintainable Laravel applications.
What Is Middleware?
Middleware is a filtering mechanism that intercepts HTTP requests entering your application. Think of it as a series of checkpoints a request must pass through before it reaches your controller — and a series of exit points a response passes through on its way back to the client.
Common use cases include:
- Authenticating users before granting access to protected routes
- Verifying CSRF tokens on form submissions
- Throttling API requests to prevent abuse
- Logging incoming requests for debugging or auditing
- Transforming request or response data
Laravel ships with several built-in middleware classes located in app/Http/Middleware, and you can create your own at any point.
The HTTP Kernel — Where It All Begins
Every HTTP request in Laravel is handled by the HTTP Kernel, located at app/Http/Kernel.php. This is the entry point for request processing and the place where all middleware is registered and organized.
The Kernel defines two types of middleware stacks:
Global Middleware
Runs on every single request to your application, regardless of route. Defined in the $middleware array:
protected $middleware = [
\App\Http\Middleware\TrustProxies::class,
\Illuminate\Http\Middleware\HandleCors::class,
\App\Http\Middleware\PreventRequestsDuringMaintenance::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
\Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class,
];
Middleware Groups
Groups allow you to bundle multiple middleware under a single key and apply them to routes together. The two built-in groups are web and api:
protected $middlewareGroups = [
'web' => [
\App\Http\Middleware\EncryptCookies::class,
\Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\VerifyCsrfToken::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
'api' => [
\Illuminate\Routing\Middleware\ThrottleRequests::class.':api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
Route Middleware (Middleware Aliases)
Named middleware that can be applied selectively to specific routes or route groups:
protected $middlewareAliases = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
'verified' => \Illuminate\Auth\Middleware\EnsureEmailIsVerified::class,
];
The Request Lifecycle — Step by Step
Understanding the full journey of a request through Laravel gives you a clear mental model for debugging and designing middleware.
Step 1 — Entry Point (public/index.php)
Every request starts at public/index.php. This file bootstraps the Laravel application and creates an instance of the HTTP Kernel:
$kernel = $app->make(Illuminate\Contracts\Http\Kernel::class);
$response = $kernel->handle(
$request = Illuminate\Http\Request::capture()
);
$response->send();
$kernel->terminate($request, $response);
Step 2 — Service Providers Bootstrap
Before middleware runs, Laravel boots all registered service providers — loading configuration, database connections, event listeners, and more. This happens inside the Kernel’s bootstrap process.
Step 3 — Global Middleware Runs (Before)
The request enters the global middleware pipeline. Each middleware’s handle() method is called in sequence. The $next closure passes the request forward to the next layer.
public function handle(Request $request, Closure $next): Response
{
// Code here runs BEFORE the request reaches the controller
$response = $next($request);
// Code here runs AFTER the controller returns a response
return $response;
}
Step 4 — Router Matches the Request
The router examines the request URI and HTTP verb to find a matching route. If no match is found, a 404 response is returned immediately.
Step 5 — Route Middleware Runs (Before)
Middleware assigned to the matched route — either directly or via a group — now runs in the order they were defined.
Route::get('/dashboard', [DashboardController::class, 'index'])
->middleware(['auth', 'verified']);
In this example, auth runs first. If the user is not authenticated, the request is redirected to the login page and verified never runs. If authentication passes, verified checks whether the email is confirmed.
Step 6 — Controller / Route Closure Executes
The request finally reaches its destination — your controller method or route closure. This is where your actual application logic runs.
Step 7 — Response Travels Back Through Middleware (After)
This is the part developers most often overlook. After the controller returns a response, the response travels back through the middleware stack in reverse order. The code after $next($request) in each middleware now executes.
This is where you can:
- Add or modify response headers
- Log response data
- Compress response content
- Append cookies to the response
Step 8 — Response Sent to Client
The final response is sent back to the browser or API client via $response->send().
Step 9 — Terminable Middleware
After the response is sent, Laravel calls the terminate() method on any middleware that implements it. This is useful for tasks that should happen after the response is delivered — such as logging, cleanup, or sending analytics events — without adding latency to the response itself.
public function terminate(Request $request, Response $response): void
{
// Runs after response is sent — zero impact on response time
Log::info('Request completed', [
'url' => $request->url(),
'status' => $response->getStatusCode(),
'time' => now(),
]);
}
Writing Custom Middleware
Creating middleware in Laravel is straightforward. Use Artisan to generate the class:
php artisan make:middleware EnsureUserIsActive
This creates app/Http/Middleware/EnsureUserIsActive.php:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsActive
{
public function handle(Request $request, Closure $next): Response
{
if (! $request->user() || ! $request->user()->is_active) {
return redirect('/inactive-account');
}
return $next($request);
}
}
Register it in Kernel.php under $middlewareAliases:
'active' => \App\Http\Middleware\EnsureUserIsActive::class,
Apply it to routes:
Route::middleware(['auth', 'active'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::get('/settings', [SettingsController::class, 'index']);
});
Middleware with Parameters
Middleware can accept runtime parameters — useful for role-based access control or feature flags.
public function handle(Request $request, Closure $next, string $role): Response
{
if (! $request->user()->hasRole($role)) {
abort(403, 'Unauthorized.');
}
return $next($request);
}
Pass the parameter in the route definition using a colon separator:
Route::get('/admin', [AdminController::class, 'index'])
->middleware('role:admin');
Route::get('/editor', [EditorController::class, 'index'])
->middleware('role:editor');
Multiple parameters are comma-separated:
->middleware('role:admin,editor')
Middleware Execution Order
The order middleware runs matters — and it is controllable. In Laravel 11+, middleware priority is managed via the $middlewarePriority array in the Kernel, or using the withMiddleware() method in bootstrap/app.php.
Before the controller: Middleware runs in the order it was registered — global first, then group, then route-specific.
After the controller: The response travels back in reverse order — route-specific first, then group, then global.
Visualizing this as a nested structure helps:
→ Global Middleware (outermost layer)
→ Middleware Group (web / api)
→ Route Middleware
→ Controller Action
← Route Middleware (response back)
← Middleware Group (response back)
← Global Middleware (response back)
Before vs. After Middleware
The position of your code relative to $next($request) determines whether middleware logic runs before or after the controller.
Before Middleware — runs code before passing the request forward:
public function handle(Request $request, Closure $next): Response
{
// Runs BEFORE controller
$request->merge(['processed_at' => now()]);
return $next($request);
}
After Middleware — runs code after the controller returns a response:
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Runs AFTER controller
$response->headers->set('X-Custom-Header', 'value');
return $response;
}
Both — runs code on both sides:
public function handle(Request $request, Closure $next): Response
{
// Before
$startTime = microtime(true);
$response = $next($request);
// After
$duration = microtime(true) - $startTime;
$response->headers->set('X-Response-Time', $duration . 'ms');
return $response;
}
Excluding Routes from Middleware
Sometimes you need a route to bypass middleware that is applied to a group. Use the withoutMiddleware() method:
Route::middleware(['auth'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::get('/health', [HealthController::class, 'check'])
->withoutMiddleware(['auth']); // Public health check endpoint
});
Common Mistakes to Avoid
1. Forgetting the $next call Not calling $next($request) in your middleware will stop the request dead — the controller never runs and the response never returns. Always call it unless you are intentionally short-circuiting (like in auth middleware that redirects unauthenticated users).
2. Heavy logic in middleware Middleware runs on every matched request. Expensive database queries or external API calls in middleware can silently degrade your application’s performance at scale. Keep middleware lean and fast.
3. Wrong registration type Adding middleware that should be route-specific to the global stack means it runs on every request — including ones that don’t need it. Be intentional about where each middleware is registered.
4. Ignoring middleware order If auth middleware runs after custom middleware that accesses $request->user(), you’ll get null — because the user hasn’t been resolved yet. Always think about execution sequence.
Summary
Laravel’s middleware lifecycle follows a clean pipeline pattern:
- Request enters through
public/index.php - HTTP Kernel bootstraps the application
- Global middleware runs (before)
- Router matches the route
- Route middleware runs (before)
- Controller executes
- Route middleware runs (after — reverse order)
- Global middleware runs (after — reverse order)
- Response sent to client
- Terminable middleware runs (post-response cleanup)
Mastering this flow gives you precise control over every request and response in your application — from authentication and authorization to logging, performance monitoring, and response transformation. Middleware is one of Laravel’s most powerful tools, and understanding its lifecycle unlocks the full potential of the framework.