Laravel Mastery: Advanced middleware patterns

Laravel middleware beyond the basics: parameters, groups, terminating middleware, multi-tenant context and layered authentication, with tests for each.

Updated 13 min read

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

Keep middleware focused and single-purpose
Use middleware groups to avoid repetition
Document what each parameter does
Return appropriate HTTP status codes
Log the access decisions middleware makes
Test middleware in isolation
Use terminating middleware for post-response tasks
Measure what each layer costs

Don’t

Run database queries in global middleware
Do heavy work before the response is sent
Mix authentication and business logic
Ignore middleware execution order
Create circular dependencies
Forget to register middleware where your Laravel version expects it
Hard-code values that belong in configuration
Skip error handling

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

  1. Advanced Eloquent relationships: polymorphic relations and complex queries
  2. Custom Artisan commands: CLI automation and task runners
  3. Advanced middleware patterns (this post): request processing, API versioning and authentication
  4. Event-driven architecture: events, listeners and broadcasting
  5. Performance optimisation: caching, query optimisation, profiling
  6. Deployment and DevOps: CI/CD and production workflows

On using AI to write middleware:

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.

Related posts