Request Middleware Controller
Backend

Laravel Middleware: A Complete Guide

Mayur Dabhi
Mayur Dabhi
March 10, 2026
18 min read

You add an "is this user an admin?" check to one controller, then copy it into a second, then a third. A few weeks later someone adds a new admin route, forgets the check, and nobody notices until a regular user finds the page. That kind of logic belongs in middleware, where it runs before the controller and can't be skipped by accident.

This post covers how Laravel's middleware pipeline works, how to write and register your own (including the Laravel 11 bootstrap/app.php way), parameters, terminable middleware, groups, priority, testing, and a few gotchas that tend to show up in production.

Why It Matters

Middleware sits between the incoming request and your controllers. If auth, headers and logging live there, controllers stay small and you get one place to look when a request is rejected or modified unexpectedly.

Understanding the Middleware Pipeline

Think of middleware as layers wrapped around your application. Each HTTP request passes through these layers before it reaches your controller, and the response passes back through them on the way out.

Laravel Middleware Pipeline (Onion Model) Global Middleware Layer Route Middleware Layer Controller Middleware Your Application HTTP Request HTTP Response Runs on ALL requests Runs on specific routes Runs on controller actions

Middleware wraps your application like layers of an onion: requests pass inward, responses pass outward

Creating Your First Middleware

Artisan generates the class for you:

Terminal
php artisan make:middleware CheckAge

The new class lands in app/Http/Middleware and looks like this once you add a check:

app/Http/Middleware/CheckAge.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class CheckAge
{
    /**
     * Handle an incoming request.
     *
     * @param  \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response)  $next
     */
    public function handle(Request $request, Closure $next): Response
    {
        if ($request->age <= 18) {
            return redirect('home')->with('error', 'You must be over 18.');
        }

        return $next($request);
    }
}

The $next closure is what passes the request to the next middleware in the pipeline. If you don't call $next($request), the request stops at your middleware and never reaches the controller.

Before & After Middleware

Where you put your logic relative to $next($request) decides whether it runs before the controller, after it, or both:

Executes logic BEFORE the request reaches the controller:

public function handle(Request $request, Closure $next): Response
{
    // This runs BEFORE the request reaches the controller
    if (!$this->checkUserPermission($request)) {
        abort(403, 'Unauthorized');
    }

    return $next($request); // Pass to next layer
}

Executes logic AFTER the response is generated:

public function handle(Request $request, Closure $next): Response
{
    $response = $next($request); // Get response first

    // This runs AFTER the controller generates response
    $response->header('X-Custom-Header', 'MyValue');
    
    return $response;
}

Do work on both sides of the controller. Note that microtime(true) returns seconds, so multiply by 1000 if you actually want milliseconds in the log:

public function handle(Request $request, Closure $next): Response
{
    // BEFORE: Start timing
    $startTime = microtime(true);
    
    // Process request through pipeline
    $response = $next($request);
    
    // AFTER: Log duration
    $duration = microtime(true) - $startTime;
    Log::info("Request took {$duration}ms");
    
    return $response;
}

Registering Middleware

A middleware class does nothing until you register it or reference it on a route. Since Laravel 11 there's no HTTP Kernel anymore; registration happens in bootstrap/app.php:

bootstrap/app.php (Laravel 11+)
<?php

use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
use App\Http\Middleware\CheckAge;
use App\Http\Middleware\LogRequests;
use App\Http\Middleware\AdminOnly;

return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__.'/../routes/web.php',
        api: __DIR__.'/../routes/api.php',
    )
    ->withMiddleware(function (Middleware $middleware) {
        // Global middleware (runs on every request)
        $middleware->append(LogRequests::class);
        
        // Aliased middleware (use by name in routes)
        $middleware->alias([
            'age' => CheckAge::class,
            'admin' => AdminOnly::class,
        ]);
        
        // Middleware groups
        $middleware->appendToGroup('web', [
            // Additional web middleware
        ]);
        
        $middleware->appendToGroup('api', [
            // Additional API middleware
        ]);
    })
    ->create();
Laravel 10 and Earlier

In Laravel 10 and earlier versions, middleware is registered in app/Http/Kernel.php using the $middleware, $middlewareGroups, and $middlewareAliases arrays.

Middleware Types Overview

Type Scope Use Case
Global Every HTTP request Logging, CORS, Security headers
Route Specific routes Authentication, Rate limiting
Group Route groups API vs Web specific logic
Controller Controller methods Action-specific checks

Applying Middleware to Routes

There are several ways to attach middleware to routes. Here are the ones I use most:

routes/web.php
<?php

use App\Http\Controllers\ProfileController;
use App\Http\Middleware\CheckAge;
use App\Http\Middleware\VerifyApiToken;

// Single middleware using alias
Route::get('/adult-content', function () {
    return view('adult.content');
})->middleware('age');

// Multiple middleware
Route::get('/dashboard', function () {
    return view('dashboard');
})->middleware(['auth', 'verified', 'age']);

// Using middleware class directly
Route::get('/profile', [ProfileController::class, 'show'])
    ->middleware(CheckAge::class);

// Route groups with middleware
Route::middleware(['auth', 'admin'])->group(function () {
    Route::get('/admin', [AdminController::class, 'index']);
    Route::get('/admin/users', [AdminController::class, 'users']);
    Route::get('/admin/settings', [AdminController::class, 'settings']);
});

// Middleware with parameters
Route::get('/posts', [PostController::class, 'index'])
    ->middleware('role:editor,admin');

// Excluding middleware
Route::middleware('auth')->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index']);
    
    // Exclude auth from this route
    Route::get('/public-page', [PageController::class, 'public'])
        ->withoutMiddleware('auth');
});

Middleware Parameters

Middleware can take extra arguments after $next, which means one class can handle several roles instead of you writing AdminOnly, EditorOnly and so on:

app/Http/Middleware/CheckRole.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class CheckRole
{
    /**
     * Handle an incoming request.
     *
     * @param  string  ...$roles  Allowed roles
     */
    public function handle(Request $request, Closure $next, string ...$roles): Response
    {
        $user = $request->user();
        
        if (!$user) {
            return redirect('login');
        }
        
        // Check if user has any of the allowed roles
        foreach ($roles as $role) {
            if ($user->hasRole($role)) {
                return $next($request);
            }
        }
        
        abort(403, 'You do not have the required role to access this resource.');
    }
}

On the route, parameters go after a colon and are separated by commas:

routes/web.php
// Register alias in bootstrap/app.php first:
// 'role' => CheckRole::class

// Single role
Route::get('/admin', [AdminController::class, 'index'])
    ->middleware('role:admin');

// Multiple roles (any of these can access)
Route::get('/reports', [ReportController::class, 'index'])
    ->middleware('role:admin,manager,analyst');

// Combined with other middleware
Route::get('/billing', [BillingController::class, 'index'])
    ->middleware(['auth', 'verified', 'role:admin,accountant']);

Common Middleware Patterns

Most apps end up needing some version of these:

API Token Auth

Validate bearer tokens for API requests

Rate Limiting

Prevent abuse by limiting requests

CORS

Handle cross-origin requests

Maintenance Mode

Bypass for specific users/IPs

1. API Authentication Middleware

app/Http/Middleware/VerifyApiToken.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use App\Models\ApiToken;
use Symfony\Component\HttpFoundation\Response;

class VerifyApiToken
{
    public function handle(Request $request, Closure $next): Response
    {
        $token = $request->bearerToken();
        
        if (!$token) {
            return response()->json([
                'error' => 'No API token provided',
                'code' => 'TOKEN_MISSING'
            ], 401);
        }
        
        $apiToken = ApiToken::where('token', hash('sha256', $token))
            ->where('expires_at', '>', now())
            ->first();
        
        if (!$apiToken) {
            return response()->json([
                'error' => 'Invalid or expired token',
                'code' => 'TOKEN_INVALID'
            ], 401);
        }
        
        // Attach user to request for controller access
        $request->merge(['api_user' => $apiToken->user]);
        
        // Update last used timestamp
        $apiToken->touch('last_used_at');
        
        return $next($request);
    }
}

2. Request Logging Middleware

app/Http/Middleware/LogRequests.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class LogRequests
{
    public function handle(Request $request, Closure $next): Response
    {
        $startTime = microtime(true);
        
        // Process request
        $response = $next($request);
        
        // Calculate duration
        $duration = round((microtime(true) - $startTime) * 1000, 2);
        
        // Log request details
        Log::channel('requests')->info('HTTP Request', [
            'method' => $request->method(),
            'url' => $request->fullUrl(),
            'ip' => $request->ip(),
            'user_agent' => $request->userAgent(),
            'user_id' => $request->user()?->id,
            'status' => $response->getStatusCode(),
            'duration_ms' => $duration,
        ]);
        
        // Add timing header for debugging
        $response->headers->set('X-Response-Time', $duration . 'ms');
        
        return $response;
    }
}

3. JSON Response Transformer

app/Http/Middleware/TransformApiResponse.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response;

class TransformApiResponse
{
    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);
        
        // Only transform JSON responses
        if (!$response instanceof JsonResponse) {
            return $response;
        }
        
        $original = $response->getData(true);
        
        // Wrap response in standard format
        $transformed = [
            'success' => $response->isSuccessful(),
            'data' => $original,
            'meta' => [
                'timestamp' => now()->toIso8601String(),
                'version' => config('app.api_version', '1.0'),
            ],
        ];
        
        // Include errors for non-success responses
        if (!$response->isSuccessful()) {
            $transformed['error'] = [
                'code' => $response->getStatusCode(),
                'message' => $original['message'] ?? 'An error occurred',
            ];
            unset($transformed['data']);
        }
        
        return $response->setData($transformed);
    }
}

4. Security Headers Middleware

app/Http/Middleware/SecurityHeaders.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SecurityHeaders
{
    protected array $headers = [
        'X-Content-Type-Options' => 'nosniff',
        'X-Frame-Options' => 'SAMEORIGIN',
        'X-XSS-Protection' => '1; mode=block',
        'Referrer-Policy' => 'strict-origin-when-cross-origin',
        'Permissions-Policy' => 'camera=(), microphone=(), geolocation=()',
    ];

    public function handle(Request $request, Closure $next): Response
    {
        $response = $next($request);
        
        foreach ($this->headers as $header => $value) {
            $response->headers->set($header, $value);
        }
        
        // Add HSTS in production
        if (app()->isProduction()) {
            $response->headers->set(
                'Strict-Transport-Security',
                'max-age=31536000; includeSubDomains'
            );
        }
        
        return $response;
    }
}

Security Note

Register SecurityHeaders globally so error pages and redirects get the headers too, not only your happy-path routes. X-Frame-Options helps against clickjacking. X-XSS-Protection is ignored by current browsers, so treat it as legacy; a Content-Security-Policy does the real XSS work. Be careful turning on HSTS with includeSubDomains: browsers will remember it for the whole max-age, so make sure every subdomain serves HTTPS first.

Terminable Middleware

Sometimes you want work to happen after the response has gone out to the browser, so the user isn't waiting on it. Add a terminate method to the middleware and Laravel calls it at the end of the request lifecycle:

Terminable Middleware Lifecycle Request Incoming handle() Process request Controller Generate response Response Sent to client terminate() After response terminate() runs AFTER the response is sent. Good for logging, cleanup, or analytics
app/Http/Middleware/AnalyticsMiddleware.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use App\Services\AnalyticsService;
use Symfony\Component\HttpFoundation\Response;

class AnalyticsMiddleware
{
    protected $startTime;
    protected $request;

    public function __construct(
        protected AnalyticsService $analytics
    ) {}

    public function handle(Request $request, Closure $next): Response
    {
        $this->startTime = microtime(true);
        $this->request = $request;
        
        return $next($request);
    }

    /**
     * Handle tasks after the response has been sent to the browser.
     */
    public function terminate(Request $request, Response $response): void
    {
        // This runs AFTER the response is sent to the client
        // Good for slow operations that shouldn't delay response
        
        $this->analytics->track([
            'event' => 'page_view',
            'url' => $request->fullUrl(),
            'method' => $request->method(),
            'status' => $response->getStatusCode(),
            'duration' => microtime(true) - $this->startTime,
            'user_id' => $request->user()?->id,
            'ip' => $request->ip(),
            'user_agent' => $request->userAgent(),
            'referrer' => $request->header('referer'),
        ]);
    }
}
When to Use Terminate
  • Sending analytics data to external services
  • Writing to slow log storage systems
  • Cleaning up temporary resources
  • Sending non-critical notifications
Gotcha: terminate() Gets a Fresh Instance

By default Laravel resolves a new instance of the middleware from the container before calling terminate(). In the example above, $this->startTime would be null there. PHP treats that null as 0, so you don't get an error; you get a "duration" equal to the current Unix timestamp, which is a confusing thing to find in your analytics. Fix it by registering the middleware as a singleton in a service provider ($this->app->singleton(AnalyticsMiddleware::class)), or by storing the start time on the request (for example $request->attributes->set(...)). Also note the response only goes out early under PHP-FPM, where Laravel can call fastcgi_finish_request(). On other SAPIs the client waits for terminate() to finish.

Controller Middleware

If the rules differ per action, you can declare middleware on the controller itself. In Laravel 11 that means implementing HasMiddleware:

app/Http/Controllers/PostController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;

class PostController extends Controller implements HasMiddleware
{
    /**
     * Get the middleware that should be assigned to the controller.
     */
    public static function middleware(): array
    {
        return [
            // Apply to all methods
            'auth',
            
            // Apply only to specific methods
            new Middleware('verified', only: ['store', 'update', 'destroy']),
            
            // Exclude from specific methods
            new Middleware('throttle:60,1', except: ['index', 'show']),
            
            // With parameters
            new Middleware('role:editor,admin', only: ['destroy']),
        ];
    }

    public function index()
    {
        // Anyone can view (only auth required)
        return view('posts.index');
    }

    public function store()
    {
        // Requires auth + verified + throttle
    }

    public function destroy($id)
    {
        // Requires auth + verified + throttle + role:editor,admin
    }
}

Middleware Priority

Order matters more than people expect. A role check that runs before the session has started will see no user and reject everyone. You can tell Laravel how to sort middleware:

bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    // Define priority order
    $middleware->priority([
        \App\Http\Middleware\StartSession::class,
        \App\Http\Middleware\AuthenticateSession::class,
        \Illuminate\Auth\Middleware\Authenticate::class,
        \App\Http\Middleware\CheckRole::class,
        \App\Http\Middleware\CheckPermission::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ]);
})
Watch the Order

Priority only sorts middleware that are actually applied to the same route; it doesn't add anything. The rule of thumb: session first, then authentication, then authorization.

Middleware Groups

Groups let you bundle multiple middleware under a single name. Laravel comes with web and api groups by default:

bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    // Create a custom middleware group
    $middleware->group('admin', [
        'auth',
        'verified',
        \App\Http\Middleware\CheckRole::class.':admin',
        \App\Http\Middleware\LogAdminActions::class,
    ]);
    
    // Append to existing groups
    $middleware->appendToGroup('web', [
        \App\Http\Middleware\LocaleMiddleware::class,
    ]);
    
    $middleware->prependToGroup('api', [
        \App\Http\Middleware\TransformApiResponse::class,
    ]);
})

Then a route group only needs the one name:

routes/web.php
// All routes in this group get the 'admin' middleware stack
Route::middleware('admin')->prefix('admin')->group(function () {
    Route::get('/', [AdminController::class, 'dashboard']);
    Route::get('/users', [AdminController::class, 'users']);
    Route::get('/analytics', [AdminController::class, 'analytics']);
});

Testing Middleware

Since middleware is often your access control, I'd test it through real HTTP requests rather than unit testing the class in isolation. Feature tests catch registration mistakes too, like an alias that was never added:

tests/Feature/MiddlewareTest.php
<?php

namespace Tests\Feature;

use Tests\TestCase;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;

class MiddlewareTest extends TestCase
{
    use RefreshDatabase;

    public function test_guest_cannot_access_protected_route(): void
    {
        $response = $this->get('/dashboard');
        
        $response->assertRedirect('/login');
    }

    public function test_authenticated_user_can_access_protected_route(): void
    {
        $user = User::factory()->create();
        
        $response = $this->actingAs($user)
            ->get('/dashboard');
        
        $response->assertOk();
    }

    public function test_non_admin_cannot_access_admin_routes(): void
    {
        $user = User::factory()->create(['role' => 'user']);
        
        $response = $this->actingAs($user)
            ->get('/admin');
        
        $response->assertForbidden();
    }

    public function test_admin_can_access_admin_routes(): void
    {
        $admin = User::factory()->create(['role' => 'admin']);
        
        $response = $this->actingAs($admin)
            ->get('/admin');
        
        $response->assertOk();
    }

    public function test_middleware_can_be_disabled_for_testing(): void
    {
        // Disable all middleware for this test
        $response = $this->withoutMiddleware()
            ->get('/protected-route');
        
        $response->assertOk();
    }

    public function test_specific_middleware_can_be_disabled(): void
    {
        $response = $this->withoutMiddleware([
            \App\Http\Middleware\CheckRole::class,
        ])->get('/admin');
        
        // Other middleware still runs, just not CheckRole
    }
}

Best Practices

1

Keep Middleware Focused

One job per class. A single middleware that authenticates, logs and transforms responses is hard to reorder, hard to exclude from one route, and hard to test.

2

Use Dependency Injection

Inject services through the constructor instead of using facades. This makes your middleware more testable and follows SOLID principles.

3

Always Return a Response

Every path through your middleware must either call $next($request) or return a response. Forget it and you'll typically see a TypeError saying the return value must be of type Response, null returned.

4

Be Careful with Global Middleware

Global middleware runs on every request. Keep them lightweight and fast. Heavy operations should be in route-specific middleware.

5

Use Terminable Middleware Wisely

terminate() keeps slow work out of the response time, but it still runs synchronously inside the same PHP worker, which stays busy until it finishes. For anything that can fail or retry, dispatch a queued job instead.

When I'd Use Middleware (and When I Wouldn't)

Middleware is the right place for anything that's about the request as a whole and applies to many routes: authentication, rate limiting, security headers, locale detection, request logging. If you find the same if at the top of several controller methods, that's usually a sign it should move.

I'd avoid it in a few cases:

Before shipping, run php artisan route:list -v to see which middleware each route actually gets. It's the quickest way to spot a route that's missing auth, or one where a group and a withoutMiddleware() call cancel each other out.

Laravel Middleware PHP Authentication Security Backend
Mayur Dabhi

Mayur Dabhi

Full-stack developer passionate about Laravel, React, and building clean, efficient web applications. Sharing knowledge to help developers level up their skills.