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.

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):

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):

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):

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:

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

Ready to implement advanced middleware patterns? Contact us to discuss architecture strategies for your Laravel application.