Middleware in Laravel: From Zero to Production
Middleware in Laravel: Request Filtering & Pipeline Architecture
Reading Time: 25 minutes
Introduction
Middleware provides a powerful mechanism for filtering and manipulating HTTP requests entering your application. Think of it as a series of layers — each can inspect, modify, or reject the request before it reaches your application, and can also modify the response on its way back.
Laravel's middleware system is built on the Pipeline design pattern, which chains handlers together so each passes the request to the next.
The Problem
Without middleware, every controller would need to repeat the same boilerplate:
class PostController extends Controller
{
public function store(Request $request): JsonResponse
{
if (!auth()->check()) {
return response()->json(['error' => 'Unauthenticated'], 401);
}
if (auth()->user()->cannot('create', Post::class)) {
return response()->json(['error' => 'Forbidden'], 403);
}
$validated = validator($request->all(), [
'title' => 'required|string|max:255',
])->validate();
// ... actual business logic
}
}
Middleware extracts cross-cutting concerns into reusable, testable classes.
How Middleware Works
sequenceDiagram
participant C as Client
participant M1 as Middleware 1
participant M2 as Middleware 2
participant M3 as Middleware 3
participant R as Route/Controller
C->>M1: Request
M1->>M2: Request
M2->>M3: Request
M3->>R: Request
R-->>M3: Response
M3-->>M2: Response
M2-->>M1: Response
M1-->>C: Response
Each middleware receives the request, performs its logic, then calls $next($request) to pass it to the next layer.
Creating Middleware
php artisan make:middleware EnsureTokenIsValid
class EnsureTokenIsValid
{
public function handle(Request $request, Closure $next): Response
{
if ($request->header('X-API-Key') !== config('app.api_key')) {
return response()->json(['error' => 'Invalid API key'], 401);
}
return $next($request);
}
}
Before vs After Middleware
Before middleware — runs before the request reaches the controller:
class BeforeMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// Perform action before request
Log::info('Request started', ['url' => $request->url()]);
return $next($request);
}
}
After middleware — runs after the controller returns a response:
class AfterMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
// Perform action after response is generated
Log::info('Request completed', [
'url' => $request->url(),
'status' => $response->status(),
]);
return $response;
}
}
Registering Middleware
Global Middleware
Runs on every HTTP request. Defined in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->append(EnsureTokenIsValid::class);
$middleware->prepend(TrustHosts::class);
})
Route Middleware
Assigned to specific routes or groups. Registered in bootstrap/app.php:
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'auth' => Authenticate::class,
'verified' => EnsureEmailIsVerified::class,
'throttle' => ThrottleRequests::class,
]);
})
Usage on routes:
Route::middleware('auth')->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']);
Route::get('/profile', [ProfileController::class, 'edit']);
});
Middleware Groups
Groups bundle multiple middleware under one key:
->withMiddleware(function (Middleware $middleware) {
$middleware->group('api', [
'throttle:api',
EnsureTokenIsValid::class,
]);
})
Laravel ships with two default groups: web and api.
Middleware Parameters
Middleware can accept additional parameters:
class CheckRole
{
public function handle(Request $request, Closure $next, string ...$roles): Response
{
if (!auth()->check()) {
return redirect('login');
}
foreach ($roles as $role) {
if (auth()->user()->hasRole($role)) {
return $next($request);
}
}
abort(403, 'Unauthorized action.');
}
}
Registration:
$middleware->alias(['role' => CheckRole::class]);
Usage:
Route::get('/admin', function () { ... })->middleware('role:admin,super-admin');
TrustProxies Middleware
Critical for applications behind load balancers or reverse proxies:
class TrustProxies extends Middleware
{
protected $proxies = '*';
protected $headers = Request::HEADER_X_FORWARDED_FOR |
Request::HEADER_X_FORWARDED_HOST |
Request::HEADER_X_FORWARDED_PORT |
Request::HEADER_X_FORWARDED_PROTO |
Request::HEADER_X_FORWARDED_AWS_ELB;
}
Without this, url()->secure(), redirect()->intended(), and rate limiting may misbehave behind a proxy.
Rate Limiting Middleware
Laravel's throttle middleware prevents abuse:
// Basic rate limiting
Route::middleware('throttle:60,1')->group(function () {
Route::get('/api/users', [UserController::class, 'index']);
});
// Named rate limiters (defined in AppServiceProvider or RouteServiceProvider)
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
// Dynamic limits based on user
RateLimiter::for('premium-api', function (Request $request) {
if ($request->user()?->isPremium()) {
return Limit::perMinute(1000);
}
return Limit::perMinute(60);
});
// Burst limiting
RateLimiter::for('burst', function (Request $request) {
return [
Limit::perMinute(60),
Limit::perSecond(5),
];
});
Custom Rate Limiter Responses
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)
->by($request->ip())
->response(function (Request $request, array $headers) {
return response()->json([
'message' => 'Too many requests. Please slow down.',
'retry_after' => $headers['Retry-After'] ?? 60,
], 429);
});
});
Sortable Middleware
Control the order middleware runs within its group:
->withMiddleware(function (Middleware $middleware) {
$middleware->appendToGroup('web', EnsureTokenIsValid::class);
$middleware->prependToGroup('web', CheckForMaintenanceMode::class);
})
Priority
For advanced ordering, use priority:
->withMiddleware(function (Middleware $middleware) {
$middleware->priority([
StartSession::class,
ShareErrorsFromSession::class,
EncryptCookies::class,
]);
})
Terminable Middleware
Middleware that runs after the response has been sent to the browser:
class TerminatingMiddleware
{
public function handle(Request $request, Closure $next): Response
{
return $next($request);
}
public function terminate(Request $request, Response $response): void
{
// Log, queue notifications, close connections
Log::info('Request fully terminated', [
'memory' => memory_get_peak_usage(true),
]);
}
}
Great for:
- Logging — request duration and memory usage
- Cleanup — closing connections, flushing temporary state
- Deferred work — dispatching jobs you want to fire after the response
Built-in Middleware Reference
| Middleware | Purpose |
|---|---|
Authenticate (auth) | Ensure user is logged in |
EnsureEmailIsVerified (verified) | Redirect unverified users |
ThrottleRequests (throttle) | Rate limit routes |
EncryptCookies | Encrypt all cookies |
TrimStrings | Trim whitespace from input |
TrustProxies | Configure trusted proxies |
ValidateCsrfToken | CSRF protection |
SubstituteBindings | Replace route params with models |
RedirectIfAuthenticated (guest) | Redirect logged-in users |
Common Mistakes
Mistake 1: Order Dependency in Global Middleware
// ❌ Session-dependent middleware before sessions start
$middleware->append(CheckUserRole::class);
// ✅ Ensure session is active first
$middleware->appendToGroup('web', CheckUserRole::class);
Mistake 2: Heavy Operations in Global Middleware
// ❌ Every request pays this cost
class GlobalMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$user = User::with('permissions')->find(auth()->id()); // DB query
View::share('currentUser', $user); // Memory
return $next($request);
}
}
Fix: Use lazy loading or view composers instead.
Mistake 3: Ignoring CSRF on API Routes
// ❌ CSRF middleware on stateless API (will fail for non-browser clients)
Route::middleware('api')->group(function () {
// API routes still have VerifyCsrfToken by default if not in api group
});
Fix: API routes should use the api middleware group which excludes CSRF.
Mistake 4: Not Using Named Rate Limiters
// ❌ Inline throttle is hard to manage
Route::get('/api/export', ...)->middleware('throttle:10,1');
// ✅ Named limiters are reusable and testable
RateLimiter::for('export', fn ($req) => Limit::perMinute(10));
Route::get('/api/export', ...)->middleware('throttle:export');
Best Practices Summary
- Use middleware for cross-cutting concerns — auth, logging, rate limiting, CORS
- Keep middleware focused — one responsibility per middleware
- Use middleware groups over global — don't slow down every request
- Register named rate limiters — testable and reusable
- Use terminable middleware for post-response work — don't delay the response
- Use
prependand priority — control execution order explicitly - Test middleware in isolation — use
$this->call()with middleware
Interview Questions
- Explain the middleware pipeline and how
$next($request)works. - What is the difference between global, route, and group middleware?
- How do middleware parameters work?
- What does
TrustProxiesmiddleware do and why is it important? - How would you implement role-based access control with middleware?
- Explain the
terminate()method and when you would use it. - How does the throttle middleware work under the hood?