Laravel Advanced Middleware Patterns: Building Powerful HTTP Request Pipelines

13 minute read Laravel Mastery · Part 3

Master Laravel middleware with advanced patterns including dynamic pipelines, request transformation, response modification, multi-tenant architecture, and layered authentication strategies with production-ready examples.

Laravel’s middleware system is far more powerful than simple authentication guards. It’s a sophisticated request/response pipeline that enables you to transform requests, modify responses, inject context, and build layered architecture patterns. Understanding advanced middleware patterns is essential for building scalable, maintainable Laravel applications.

This comprehensive guide covers everything from basic middleware concepts to advanced patterns including dynamic pipelines, terminating middleware, parameter-based logic, multi-tenant context injection, and real-world implementations.

Important Note for Laravel 11+ Projects

If you are using the Laravel 11+ application skeleton, middleware registration is configured in bootstrap/app.php via ->withMiddleware(...) rather than app/Http/Kernel.php. Many real-world codebases still use the Laravel 10 structure, so the concepts and middleware classes remain valid, but the registration location differs.

Why Middleware Is a Strategic Layer

Middleware is one of the cleanest places to enforce cross-cutting concerns:

  • Authentication and authorisation boundaries
  • Tenant resolution and context injection
  • Rate limiting and abuse protection
  • Response headers and cache behaviour
  • Request normalisation and input shaping

When middleware is done well, controllers and services become simpler because the request arrives already “prepared”.

Design Rationale and Trade-offs

Keep middleware thin

Middleware should not become a second controller layer. If you need multiple queries, complex rules, or branching flows, call an application service and keep the middleware a wrapper.

Be deliberate about order

Order is a hidden source of bugs. If tenancy runs after authorisation, you will authorise against the wrong context. If throttling runs after expensive work, you have already paid the cost.

Avoid database work in global middleware

Global middleware runs on every request, including health checks and static route hits. If you need to query, prefer route groups where possible, or cache aggressively.

Security and Operational Notes

  • Never cache personalised responses at the edge without strict cookie and header rules.
  • Treat tenant resolution as security sensitive. Validate inputs and fail closed.
  • Rate limiting should vary by auth state (guest vs user vs admin) and by endpoint risk.

FAQ

Is middleware the right place for feature flags?
Often yes, if it is a gate. For UI-level toggles, it may belong in the frontend or view models instead.

Can terminating middleware replace queues?
No. Terminating middleware still runs in the request lifecycle. Use queues for anything that can take more than a moment or relies on third parties.

What is the most common middleware mistake?
Doing too much work and hiding it from the rest of the app.

Key Takeaways

  • Middleware is best for cross-cutting concerns, not business rules.
  • Order matters more than people expect.
  • Keep global middleware cheap and predictable.

Understanding Laravel’s Middleware Pipeline

How Middleware Works

Laravel processes HTTP requests through a pipeline of middleware layers, each capable of inspecting, modifying, or rejecting requests before they reach your application logic.

The Pipeline 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 (app/Http/Kernel.php in Laravel 10 and earlier; bootstrap/app.php in Laravel 11+)):

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 to your application. Use sparingly for critical operations.

2. Middleware Groups (app/Http/Kernel.php in Laravel 10 and earlier; bootstrap/app.php in Laravel 11+)):

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,
    ],
];

Applied to routes based on group assignment (web vs api).

3. Route Middleware (app/Http/Kernel.php in Laravel 10 and earlier; bootstrap/app.php in Laravel 11+)):

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,
];

Applied selectively to specific routes or route groups.

Learn how middleware integrates with Laravel 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

Execute 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

Modify the response after controller execution:

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

Short-circuit the pipeline without calling subsequent middleware:

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

Define parameterised middleware in routes:

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 in app/Http/Kernel.php (Laravel 10 and earlier skeletons):

protected $routeMiddleware = [
    'role' => \App\Http\Middleware\CheckRole::class,
];

Advanced: 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 Architecture Patterns

Tenant Context Injection

<?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;
    }
}

Tenant Database Connection Switching

<?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);
    }
}

Learn about event-driven architecture for tenant-specific events.

Request Transformation Patterns

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']));
    }
}

Review Laravel API design patterns for comprehensive versioning strategies.

Terminating Middleware

Understanding Terminating Middleware

Terminating middleware runs after the response has been sent to the browser. This is perfect for operations that don’t need to block the response.

<?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}");
}

Learn about Laravel performance optimisation for advanced tracking.

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']);
    });

Priority and Order Control

// 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,
];

Middleware executes in this order when multiple are applied.

Advanced Patterns

Conditional Middleware Execution

<?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

Basic Middleware Testing

<?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();
}

Learn about Laravel testing strategies for comprehensive coverage.

Best Practices

Do:

Keep middleware focused and single-purpose
Use middleware groups to avoid repetition
Document dynamic parameters clearly
Return appropriate HTTP status codes
Log important middleware decisions
Test middleware in isolation
Use terminating middleware for post-response tasks
Consider performance impact

Don’t:

Run database queries in global middleware
Perform heavy computations that block response
Mix authentication and business logic
Ignore middleware execution order
Create circular dependencies
Forget to register middleware in Kernel
Hardcode values - use configuration
Skip error handling

Key Takeaways

  • Understand the pipeline: Global → Group → Route middleware execution order
  • Use parameters for flexible, reusable middleware
  • Leverage middleware groups to avoid repetition and maintain consistency
  • Implement terminating middleware for post-response operations
  • Build multi-tenant architecture using context injection middleware
  • Test thoroughly with feature tests covering all scenarios
  • Monitor performance especially for global middleware
  • Document clearly especially for complex or parameterised middleware

Complete Series Navigation

Continue mastering Laravel with the full 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 and authentication
  4. Event-Driven Architecture – Events, listeners, and observers
  5. Advanced Testing Strategies – Comprehensive test coverage
  6. Performance Optimisation – Caching, query optimisation, profiling
  7. API Design and Documentation – RESTful APIs and OpenAPI
  8. Deployment and DevOps – CI/CD and production workflows

For AI-powered middleware generation:

Need Laravel development help? I work with businesses across Chester and the North West. Learn more about my Laravel services or get in touch.