Laravel Middleware: A Complete Guide
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.
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.
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:
php artisan make:middleware CheckAge
The new class lands in app/Http/Middleware and looks like this once you add a check:
<?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:
<?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();
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:
<?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:
<?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:
// 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
<?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
<?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
<?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
<?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:
<?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'),
]);
}
}
- Sending analytics data to external services
- Writing to slow log storage systems
- Cleaning up temporary resources
- Sending non-critical notifications
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:
<?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:
->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,
]);
})
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:
->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:
// 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:
<?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
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.
Use Dependency Injection
Inject services through the constructor instead of using facades. This makes your middleware more testable and follows SOLID principles.
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.
Be Careful with Global Middleware
Global middleware runs on every request. Keep them lightweight and fast. Heavy operations should be in route-specific middleware.
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:
- Per-model authorization. "Can this user edit this post?" needs the loaded model. Policies and
$this->authorize()(or thecanmiddleware with route model binding) fit that better than a custom class that queries the database again. - Business logic. If a middleware starts creating orders or sending emails, it's hiding behaviour from anyone reading the controller.
- Heavy work in global middleware. Every request pays for it, including health checks and asset 404s. Push it to route middleware or a queued job.
- Reshaping every JSON response. A transformer like the one above works, but API Resources make the shape visible where the data is built, and they don't break when a controller returns something other than a
JsonResponse.
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.