Laravel middleware is a pipeline that every request passes through on its way to a controller, and every response passes back through. As well as guarding routes behind a login, it can rewrite requests, change responses, set context such as the current tenant, and run work after the response has gone. It keeps rules like these out of your controllers.
This guide starts with how the pipeline works, then covers parameters, multi-tenant context, request rewriting, terminating middleware, groups and testing.
A note on Laravel 11 and later
From Laravel 11, the application skeleton registers middleware in bootstrap/app.php, with ->withMiddleware(...), in place of app/Http/Kernel.php. Many codebases still use the Laravel 10 structure. The middleware classes work the same in both, and only the place you register them changes.
The middleware pipeline
How middleware works
Each request passes through the middleware layers in turn. Any layer can inspect it, change it or reject it before it reaches your controller.
The flow:
Request → Global Middleware → Route Middleware → Controller → Response
↑ ↓
└─────────── Terminating Middleware (optional) ────────────┘
Each middleware can:
- Pre-process the request (before passing to next layer)
- Post-process the response (after receiving from next layer)
- Short-circuit the pipeline (return early without calling next)
- Modify request or response data
- Inject services or context into the application
The three middleware layers
1. Global middleware, in bootstrap/app.php on Laravel 11 and later, or app/Http/Kernel.php before that:
protected $middleware = [
\App\Http\Middleware\TrustProxies::class,
\App\Http\Middleware\CheckForMaintenanceMode::class,
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\App\Http\Middleware\TrimStrings::class,
];
Runs on every request, so keep it to what every request needs.
2. Middleware groups:
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,
],
'api' => [
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
];
Applies to every route in a group, such as web or api.
3. Route middleware:
protected $routeMiddleware = [
'auth' => \App\Http\Middleware\Authenticate::class,
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
];
Applies only to the routes or route groups you attach it to.
Earlier parts of the series cover Eloquent relationships and custom Artisan commands.
Creating custom middleware
Basic middleware structure
Generate middleware using Artisan:
php artisan make:middleware CheckSubscriptionStatus
This creates app/Http/Middleware/CheckSubscriptionStatus.php:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class CheckSubscriptionStatus
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next)
{
// Pre-request logic here
$response = $next($request);
// Post-request logic here
return $response;
}
}
Pre-request middleware
Run logic before the request reaches the controller:
public function handle(Request $request, Closure $next)
{
if (!$request->user()->hasActiveSubscription()) {
return redirect()->route('subscription.renew')
->with('error', 'Your subscription has expired.');
}
return $next($request);
}
Post-request middleware
Change the response after the controller has run:
public function handle(Request $request, Closure $next)
{
$response = $next($request);
// Add custom headers to response
$response->headers->set('X-Request-ID', Str::uuid());
$response->headers->set('X-Processed-By', config('app.name'));
return $response;
}
Middleware with early exit
Return early, so the rest of the pipeline never runs:
public function handle(Request $request, Closure $next)
{
if ($this->isMaintenanceWindow()) {
return response()->json([
'error' => 'Service temporarily unavailable',
'retry_after' => $this->maintenanceEndsAt(),
], 503);
}
return $next($request);
}
Dynamic middleware with parameters
Passing parameters to middleware
Pass parameters after a colon in the route definition:
Route::get('/admin/dashboard', [DashboardController::class, 'index'])
->middleware('role:admin');
Route::get('/editor/posts', [PostController::class, 'index'])
->middleware('role:admin,editor');
Route::put('/posts/{post}', [PostController::class, 'update'])
->middleware('permission:edit-posts');
Implementing parameterised middleware
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class CheckRole
{
public function handle(Request $request, Closure $next, ...$roles)
{
if (!$request->user()) {
return redirect()->route('login');
}
foreach ($roles as $role) {
if ($request->user()->hasRole($role)) {
return $next($request);
}
}
abort(403, 'Unauthorized action.');
}
}
Register it in bootstrap/app.php on Laravel 11 and later, or in app/Http/Kernel.php on Laravel 10 and earlier:
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'role' => \App\Http\Middleware\CheckRole::class,
]);
})
// app/Http/Kernel.php (Laravel 10; the property was $routeMiddleware before that)
protected $middlewareAliases = [
'role' => \App\Http\Middleware\CheckRole::class,
];
Permission-based access
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class CheckPermission
{
public function handle(Request $request, Closure $next, string $permission, string $resource = null)
{
$user = $request->user();
if (!$user) {
return redirect()->route('login');
}
// Check direct permission
if ($user->hasPermission($permission)) {
return $next($request);
}
// Check resource-specific permission
if ($resource && $request->route($resource)) {
$model = $request->route($resource);
if ($user->hasPermissionForResource($permission, $model)) {
return $next($request);
}
}
abort(403, "You don't have permission to {$permission}.");
}
}
Usage:
Route::put('/posts/{post}', [PostController::class, 'update'])
->middleware('permission:edit,post');
Multi-tenant applications
Setting the tenant
<?php
namespace App\Http\Middleware;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
class IdentifyTenant
{
public function handle(Request $request, Closure $next)
{
$tenant = $this->resolveTenant($request);
if (!$tenant) {
abort(404, 'Tenant not found');
}
// Inject tenant into application container
app()->instance('tenant', $tenant);
// Set tenant context for all queries
Tenant::setCurrent($tenant);
return $next($request);
}
protected function resolveTenant(Request $request): ?Tenant
{
// Method 1: Subdomain-based
if ($subdomain = $this->extractSubdomain($request)) {
return Tenant::where('subdomain', $subdomain)->first();
}
// Method 2: Header-based (for APIs)
if ($tenantId = $request->header('X-Tenant-ID')) {
return Tenant::find($tenantId);
}
// Method 3: Path-based
if ($request->segment(1)) {
return Tenant::where('slug', $request->segment(1))->first();
}
return null;
}
protected function extractSubdomain(Request $request): ?string
{
$host = $request->getHost();
$parts = explode('.', $host);
// Return subdomain if exists (not www or apex domain)
if (count($parts) > 2 && $parts[0] !== 'www') {
return $parts[0];
}
return null;
}
}
Switching the database connection per tenant
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class SwitchTenantDatabase
{
public function handle(Request $request, Closure $next)
{
$tenant = app('tenant');
// Configure tenant-specific database connection
config([
'database.connections.tenant' => [
'driver' => 'mysql',
'host' => config('database.connections.mysql.host'),
'database' => $tenant->database_name,
'username' => $tenant->database_user,
'password' => decrypt($tenant->database_password),
],
]);
// Purge existing connection and reconnect
DB::purge('tenant');
DB::reconnect('tenant');
// Set as default connection for this request
DB::setDefaultConnection('tenant');
return $next($request);
}
}
For events that belong to one tenant, see event-driven architecture.
Rewriting requests and responses
JSON API response formatting
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class FormatJsonResponse
{
public function handle(Request $request, Closure $next)
{
$response = $next($request);
if ($response instanceof JsonResponse) {
$data = $response->getData(true);
$formatted = [
'success' => $response->isSuccessful(),
'data' => $data,
'meta' => [
'timestamp' => now()->toIso8601String(),
'version' => config('app.api_version'),
],
];
if (!$response->isSuccessful()) {
$formatted['error'] = [
'code' => $response->status(),
'message' => $this->getErrorMessage($response->status()),
];
}
$response->setData($formatted);
}
return $response;
}
protected function getErrorMessage(int $status): string
{
return match($status) {
400 => 'Bad request',
401 => 'Unauthorized',
403 => 'Forbidden',
404 => 'Resource not found',
422 => 'Validation failed',
429 => 'Too many requests',
500 => 'Internal server error',
503 => 'Service unavailable',
default => 'An error occurred',
};
}
}
Request sanitisation
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class SanitizeInput
{
protected array $except = [
'password',
'password_confirmation',
'current_password',
];
public function handle(Request $request, Closure $next)
{
$input = $request->all();
foreach ($input as $key => $value) {
if (!in_array($key, $this->except) && is_string($value)) {
$request->merge([
$key => $this->sanitize($value),
]);
}
}
return $next($request);
}
protected function sanitize(string $value): string
{
// Remove NULL bytes
$value = str_replace(chr(0), '', $value);
// Strip HTML tags (except allowed ones)
$value = strip_tags($value, '<b><i><u><a><p><br>');
// Trim whitespace
$value = trim($value);
return $value;
}
}
API versioning
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class ApiVersioning
{
public function handle(Request $request, Closure $next)
{
$version = $this->resolveVersion($request);
// Inject version into container
app()->instance('api.version', $version);
// Set version in request attributes
$request->attributes->set('api_version', $version);
// Validate version is supported
if (!$this->isSupportedVersion($version)) {
return response()->json([
'error' => "API version {$version} is not supported",
'supported_versions' => config('api.supported_versions'),
], 400);
}
return $next($request);
}
protected function resolveVersion(Request $request): string
{
// Try header first
if ($version = $request->header('X-API-Version')) {
return $version;
}
// Try Accept header
$accept = $request->header('Accept');
if (preg_match('/application\/vnd\.api\+json;version=(\d+)/', $accept, $matches)) {
return 'v' . $matches[1];
}
// Try query parameter
if ($version = $request->query('version')) {
return $version;
}
// Default version
return config('api.default_version', 'v1');
}
protected function isSupportedVersion(string $version): bool
{
return in_array($version, config('api.supported_versions', ['v1']));
}
}
Terminating middleware
How it works
Terminating middleware runs after the response has been sent to the browser. It suits work the visitor shouldn’t have to wait for, such as logging and clean-up.
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class LogRequestDetails
{
public function handle(Request $request, Closure $next)
{
return $next($request);
}
/**
* Perform tasks after the response is sent to the browser.
*/
public function terminate(Request $request, $response): void
{
Log::info('Request completed', [
'url' => $request->fullUrl(),
'method' => $request->method(),
'user_id' => $request->user()?->id,
'ip' => $request->ip(),
'status' => $response->status(),
'duration' => microtime(true) - LARAVEL_START,
'memory' => memory_get_peak_usage(true),
]);
}
}
Performance tracking
public function terminate(Request $request, $response): void
{
$duration = (microtime(true) - LARAVEL_START) * 1000; // milliseconds
// Track slow requests
if ($duration > 1000) {
Log::warning('Slow request detected', [
'url' => $request->fullUrl(),
'duration' => round($duration, 2) . 'ms',
'queries' => count(DB::getQueryLog()),
]);
}
// Send metrics to monitoring service
app('metrics')->timing('request.duration', $duration, [
'route' => $request->route()?->getName(),
'method' => $request->method(),
]);
}
Session cleanup
public function terminate(Request $request, $response): void
{
// Clean up old session data
if ($user = $request->user()) {
$user->sessions()
->where('last_activity', '<', now()->subDays(30))
->delete();
}
// Clear temporary data
cache()->forget("temp.user.{$user?->id}");
}
Laravel performance optimisation goes further on measuring requests.
Middleware groups and stacks
Creating custom middleware groups
// app/Http/Kernel.php (Laravel 10 and earlier skeletons)
protected $middlewareGroups = [
'web' => [
// Standard web middleware
],
'api' => [
// Standard API middleware
],
'api.v1' => [
'throttle:60,1',
'auth:sanctum',
'api.version:v1',
],
'api.v2' => [
'throttle:100,1',
'auth:sanctum',
'api.version:v2',
'accept:json',
],
'admin' => [
'auth',
'role:admin',
'verified',
'activity.log',
],
'tenant' => [
'tenant.identify',
'tenant.database',
'tenant.features',
],
];
Usage:
// Apply API v1 middleware group
Route::prefix('v1')
->middleware('api.v1')
->group(function () {
Route::get('/users', [UserController::class, 'index']);
});
// Apply admin middleware group
Route::middleware(['web', 'admin'])
->prefix('admin')
->group(function () {
Route::get('/dashboard', [AdminController::class, 'dashboard']);
});
Controlling the order
// app/Http/Kernel.php (Laravel 10 and earlier skeletons)
protected $middlewarePriority = [
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\App\Http\Middleware\Authenticate::class,
\Illuminate\Routing\Middleware\ThrottleRequests::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
\App\Http\Middleware\CheckRole::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
];
When several apply to one route, they run in this order.
Three more patterns
Middleware that runs only when needed
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class CheckFeatureFlag
{
public function handle(Request $request, Closure $next, string $feature)
{
$tenant = app('tenant');
if (!$tenant->hasFeature($feature)) {
if ($request->expectsJson()) {
return response()->json([
'error' => "Feature '{$feature}' not available",
], 403);
}
abort(403, "This feature is not available for your account.");
}
return $next($request);
}
}
Usage:
Route::get('/analytics', [AnalyticsController::class, 'index'])
->middleware('feature:analytics');
A/B testing middleware
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class ABTestVariant
{
public function handle(Request $request, Closure $next, string $experiment)
{
$variant = $this->getVariant($request, $experiment);
// Inject variant into application
app()->instance("experiment.{$experiment}", $variant);
// Add to request
$request->attributes->set('ab_variant', $variant);
// Track assignment
$this->trackAssignment($request, $experiment, $variant);
return $next($request);
}
protected function getVariant(Request $request, string $experiment): string
{
// Check if user already assigned
if ($user = $request->user()) {
$assignment = cache()->remember(
"experiment.{$experiment}.user.{$user->id}",
now()->addDays(30),
fn() => $this->assignVariant($user->id)
);
return $assignment;
}
// Anonymous users - use session
$sessionKey = "experiment.{$experiment}";
if (!session()->has($sessionKey)) {
session()->put($sessionKey, $this->assignVariant(session()->getId()));
}
return session($sessionKey);
}
protected function assignVariant(string $identifier): string
{
// 50/50 split
$hash = crc32($identifier);
return ($hash % 2 === 0) ? 'control' : 'variant';
}
protected function trackAssignment(Request $request, string $experiment, string $variant): void
{
// Track in analytics
event(new ExperimentAssigned($experiment, $variant, $request->user()));
}
}
Rate limiting per user role
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;
class RoleBasedRateLimit
{
public function handle(Request $request, Closure $next)
{
$user = $request->user();
if (!$user) {
return $this->limitGuest($request, $next);
}
$limits = $this->getLimitsForUser($user);
$key = $this->resolveRequestKey($request, $user);
if (RateLimiter::tooManyAttempts($key, $limits['max_attempts'])) {
return $this->buildRateLimitResponse($key);
}
RateLimiter::hit($key, $limits['decay_seconds']);
$response = $next($request);
return $this->addRateLimitHeaders($response, $key, $limits);
}
protected function getLimitsForUser($user): array
{
return match(true) {
$user->hasRole('admin') => [
'max_attempts' => 1000,
'decay_seconds' => 60,
],
$user->hasRole('premium') => [
'max_attempts' => 200,
'decay_seconds' => 60,
],
default => [
'max_attempts' => 60,
'decay_seconds' => 60,
],
};
}
protected function resolveRequestKey(Request $request, $user): string
{
return 'rate_limit:' . $user->id . ':' . $request->ip();
}
protected function limitGuest(Request $request, Closure $next)
{
$key = 'rate_limit:guest:' . $request->ip();
if (RateLimiter::tooManyAttempts($key, 20)) {
return $this->buildRateLimitResponse($key);
}
RateLimiter::hit($key, 60);
return $next($request);
}
protected function buildRateLimitResponse(string $key)
{
$retryAfter = RateLimiter::availableIn($key);
return response()->json([
'error' => 'Too many requests',
'retry_after' => $retryAfter,
], 429)
->header('Retry-After', $retryAfter)
->header('X-RateLimit-Remaining', 0);
}
protected function addRateLimitHeaders($response, string $key, array $limits)
{
$remaining = RateLimiter::remaining($key, $limits['max_attempts']);
return $response
->header('X-RateLimit-Limit', $limits['max_attempts'])
->header('X-RateLimit-Remaining', $remaining);
}
}
Testing middleware
A basic test
<?php
namespace Tests\Feature\Middleware;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class CheckSubscriptionTest extends TestCase
{
use RefreshDatabase;
public function test_active_subscription_passes(): void
{
$user = User::factory()->withActiveSubscription()->create();
$response = $this->actingAs($user)
->get('/dashboard');
$response->assertOk();
}
public function test_expired_subscription_redirects(): void
{
$user = User::factory()->withExpiredSubscription()->create();
$response = $this->actingAs($user)
->get('/dashboard');
$response->assertRedirect(route('subscription.renew'));
$response->assertSessionHas('error');
}
public function test_no_subscription_redirects(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->get('/dashboard');
$response->assertRedirect(route('subscription.renew'));
}
}
Testing middleware with parameters
public function test_admin_role_grants_access(): void
{
$admin = User::factory()->withRole('admin')->create();
$response = $this->actingAs($admin)
->get('/admin/dashboard');
$response->assertOk();
}
public function test_non_admin_role_denies_access(): void
{
$user = User::factory()->withRole('user')->create();
$response = $this->actingAs($user)
->get('/admin/dashboard');
$response->assertForbidden();
}
Do and don’t
Do
Don’t
Key takeaways
- Know the order: global middleware first, then group and route middleware
- Use parameters, so one class serves several routes
- Use middleware groups to avoid repetition and keep behaviour consistent
- Use terminating middleware for work that can wait until the response has gone
- Set the tenant in middleware, so every controller sees the same context
- Test each middleware with feature tests for both the pass and the fail
- Watch the cost of global middleware, which runs on every request
- Document parameters, especially on middleware that takes several
The Laravel Mastery series
- Advanced Eloquent relationships: polymorphic relations and complex queries
- Custom Artisan commands: CLI automation and task runners
- Advanced middleware patterns (this post): request processing, API versioning and authentication
- Event-driven architecture: events, listeners and broadcasting
- Performance optimisation: caching, query optimisation, profiling
- Deployment and DevOps: CI/CD and production workflows
On using AI to write middleware:
- AI code generation: automated middleware scaffolding
- Prompt engineering best practices: writing effective prompts
On a Laravel build, I put tenant checks and role rules in middleware like this, so no controller has to remember them.
Published , updated .
Part 3 of Laravel Mastery.