Laravel Middleware Order: Auth Before Role Prevents 403
- Code written BEFORE $next($request) runs on the request going IN — use it for auth, rate limiting, and input checks. Code written AFTER runs on the response coming OUT — use it for headers, logging, and caching.
- Middleware parameters (middleware('role:editor,moderator')) let one class handle dynamic rules, eliminating the need for a separate middleware class per role or permission level.
- Middleware chain order is security-critical: always run 'auth' before any authorisation middleware that calls Auth::user(), or you'll get null pointer errors or silent security failures.
- Middleware sits between the HTTP request and controller as a pipeline of checkpoints
- Each middleware can inspect, modify, or short-circuit the request before it reaches the controller
- Code before $next() runs on request inbound; code after runs on response outbound
- Global middleware runs on every request; route middleware is opt-in per route or group
- Middleware parameters let one class handle dynamic rules like role:editor,moderator
- The pipeline uses Illuminate\Pipeline\Pipeline; failure to return $next($request) results in empty 200 response
Middleware Quick Debug Cheat Sheet
Blank response (200, no content)
php artisan route:list | grep <route>Check middleware aliases in bootstrap/app.phpClass 'xyz' does not exist
grep -r 'xyz' app/Http/Kernel.php bootstrap/app.phpCheck exact alias string used in route file.Guest gets 403 instead of redirect to login
php artisan route:list | grep <route>Check route group middleware stack order.Production Incident
Production Debug GuideQuick diagnosis of common middleware failures
redirect()->back() for unauthorized access to prevent infinite loop.Every serious web application has invisible rules running behind every single page load. Is this user logged in? Are they an admin? Should this response be cached? Is the session still valid? Without a clean system to answer those questions, you end up scattering security checks and response tweaks all over your controllers — a maintenance nightmare waiting to happen. Laravel middleware solves that by giving you a dedicated, organised layer that sits between the incoming request and your application logic.
The problem middleware solves is cross-cutting concerns — logic that applies to many routes but doesn't belong inside any single controller. Authentication is the classic example: you don't want to paste an if (!Auth::check()) { redirect('/login'); } block at the top of fifty different controller methods. Middleware lets you declare that concern once, attach it to whichever routes need it, and never think about it again. It keeps controllers lean and focused on their actual job: returning a response.
By the end of this article you'll know how to create custom middleware from scratch, understand the difference between global, route-level and group middleware, handle the 'after' vs 'before' execution distinction that trips up most developers, and build a real role-based access guard you could drop into a production app today. You'll also know exactly what to say when an interviewer asks about middleware pipelines.
What Laravel Middleware Actually Does Under the Hood
Laravel processes every request through a pipeline — a concept borrowed from Unix pipes. The pipeline takes your request object and passes it through a stack of middleware classes, one by one. Each middleware can inspect the request, modify it, short-circuit the whole pipeline by returning a response early, or pass the request down to the next middleware by calling $next($request).
This pipeline is powered by Illuminate\Pipeline\Pipeline and is assembled in your HTTP kernel (app/Http/Kernel.php). The kernel holds three lists: $middleware (global — runs on every request), $middlewareGroups (named collections like web and api), and $middlewareAliases (short names you attach to individual routes).
The key mental model is a Russian doll. Each middleware wraps the next one. When the innermost doll (your controller) produces a response, that response travels back outward through the same stack — meaning code written after $next($request) runs on the way out, not the way in. That's what makes 'before' vs 'after' middleware tick, and it's the thing almost everyone gets wrong the first time they build custom middleware.
<?php // File: app/Http/Middleware/RequestLifecycleDemo.php // Purpose: Illustrate exactly WHEN code runs relative to the next layer. namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; class RequestLifecycleDemo { public function handle(Request $request, Closure $next): mixed { // ───────────────────────────────────────────────────────── // BEFORE phase: runs BEFORE the request reaches the controller. // Perfect for: authentication checks, rate limiting, input sanitising. // ───────────────────────────────────────────────────────── Log::info('⬇ Middleware BEFORE — request arriving', [ 'url' => $request->fullUrl(), 'method' => $request->method(), ]); // Hand the request to the next layer (another middleware or the controller). // $response contains whatever that next layer ultimately returned. $response = $next($request); // ───────────────────────────────────────────────────────── // AFTER phase: runs AFTER the controller has built the response. // Perfect for: adding headers, logging response times, caching. // ───────────────────────────────────────────────────────── $response->headers->set('X-Processed-By', 'TheCodeForge'); Log::info('⬆ Middleware AFTER — response leaving', [ 'status' => $response->getStatusCode(), ]); return $response; // Send the (possibly modified) response back up the stack. } }
[INFO] ⬇ Middleware BEFORE — request arriving {"url":"https://app.test/dashboard","method":"GET"}
[INFO] ⬆ Middleware AFTER — response leaving {"status":200}
// In the browser's Network tab → Response Headers:
X-Processed-By: TheCodeForge
Creating Real-World Middleware — A Role-Based Access Guard
Let's build something you'd actually ship. Imagine a SaaS dashboard where certain routes are only accessible to users with an 'admin' role. We'll create an EnsureUserIsAdmin middleware that checks the authenticated user's role, redirects non-admins gracefully, and can be reused across any route with a single annotation.
Run php artisan make:middleware EnsureUserIsAdmin to generate the boilerplate, then fill in the logic. The generated file lands in app/Http/Middleware/. After writing the class, you register it in bootstrap/app.php (Laravel 11+) or app/Http/Kernel.php (Laravel 10 and earlier) so the framework knows it exists.
Notice what the middleware does NOT do: it doesn't query the database for permissions lists, it doesn't render any HTML, and it doesn't touch the controller. Each of those concerns stays separate. The middleware has one job — decide whether this user is allowed through — and it does exactly that job, then gets out of the way.
This single-responsibility design is what makes middleware so composable. Need to add a 'super-admin' bypass later? Add a second middleware and stack it. Need to log every admin access? Add a logging middleware. None of them need to know about the others.
<?php // File: app/Http/Middleware/EnsureUserIsAdmin.php // Run: php artisan make:middleware EnsureUserIsAdmin namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Symfony\Component\HttpFoundation\Response; class EnsureUserIsAdmin { public function handle(Request $request, Closure $next): Response { // If no one is logged in at all, send them to the login page. if (!Auth::check()) { return redirect()->route('login') ->with('status', 'Please log in to continue.'); } // Auth::user() returns the currently authenticated User model. // We check a 'role' column on the users table — adjust to your schema. if (Auth::user()->role !== 'admin') { // Abort with 403 Forbidden and a clear reason. // This gets caught by Laravel's exception handler, // which renders your custom 403.blade.php if it exists. abort(403, 'You do not have permission to access this area.'); } // User is an admin — let the request continue normally. return $next($request); } } // ───────────────────────────────────────────────────────────────────────────── // REGISTRATION — Laravel 11+ style (bootstrap/app.php) // ───────────────────────────────────────────────────────────────────────────── // In bootstrap/app.php: // // use App\Http\Middleware\EnsureUserIsAdmin; // // ->withMiddleware(function (Middleware $middleware) { // $middleware->alias([ // 'admin' => EnsureUserIsAdmin::class, // ]); // }) // ───────────────────────────────────────────────────────────────────────────── // REGISTRATION — Laravel 10 style (app/Http/Kernel.php) // ───────────────────────────────────────────────────────────────────────────── // protected $middlewareAliases = [ // 'admin' => \App\Http\Middleware\EnsureUserIsAdmin::class, // ]; // ───────────────────────────────────────────────────────────────────────────── // USAGE IN ROUTES (routes/web.php) // ───────────────────────────────────────────────────────────────────────────── // routes/web.php use Illuminate\Support\Facades\Route; use App\Http\Controllers\AdminDashboardController; use App\Http\Controllers\ReportController; // Single route — only admins can view the dashboard. Route::get('/admin/dashboard', [AdminDashboardController::class, 'index']) ->middleware('admin') ->name('admin.dashboard'); // Route group — apply 'admin' middleware to every route inside. Route::prefix('admin') ->middleware('admin') ->name('admin.') ->group(function () { Route::get('/reports', [ReportController::class, 'index'])->name('reports'); Route::get('/users', [ReportController::class, 'users'])->name('users'); });
// → Redirected to /login with session flash: "Please log in to continue."
// Scenario 2: Logged-in user with role = 'editor' visits /admin/dashboard
// → HTTP 403 Forbidden
// → If resources/views/errors/403.blade.php exists, it renders that view.
// → Otherwise Laravel renders its default 403 page.
// Scenario 3: Logged-in user with role = 'admin' visits /admin/dashboard
// → AdminDashboardController@index runs normally. ✓
redirect()->back() for unauthorised admin access. A 403 is semantically correct (the resource exists, you just can't have it), it's loggable, and it prevents users from cycling through redirect loops if they somehow bookmark an admin URL.redirect()->back() for unauthorized access causes redirect loops when the user bookmarks an admin URL. Use abort(403) instead — it's semantically correct, loggable, and doesn't confuse browsers.Middleware Parameters and Chaining — Advanced Patterns You'll Actually Use
Hard-coding role checks inside middleware is fine for simple cases, but what if you have four roles — 'admin', 'editor', 'moderator', 'viewer'? You'd need four separate middleware classes. Middleware parameters solve this elegantly: you pass a dynamic value through the route definition, and your middleware receives it as an extra argument after $next.
Chaining is the other power move. You can stack multiple middleware on a single route, and they execute in left-to-right order. This lets you compose complex access rules from simple, reusable pieces — auth to check login, then verified to check email confirmation, then role:editor to check the role. Each piece stays independently testable.
Parameters and chaining together give you a tiny permissions DSL built right into your route file — readable, auditable, and requiring zero changes to controller code when your access rules change.
<?php // File: app/Http/Middleware/EnsureUserHasRole.php // A single middleware that handles ANY role via a parameter. namespace App\Http\Middleware; use Closure; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; use Symfony\Component\HttpFoundation\Response; class EnsureUserHasRole { /** * @param string $requiredRole — injected by Laravel from the route definition. * e.g. middleware('role:editor') sets $requiredRole = 'editor' * @param string ...$extraRoles — variadic: middleware('role:editor,moderator') passes both. */ public function handle( Request $request, Closure $next, string $requiredRole, string ...$extraRoles // Capture any additional roles passed after a comma. ): Response { // Merge the first role and any extras into one array for a clean in_array check. $allowedRoles = [$requiredRole, ...$extraRoles]; $currentUserRole = Auth::user()?->role; // ?-> safely returns null if not authenticated. if (!in_array($currentUserRole, $allowedRoles, strict: true)) { abort(403, "Access requires one of: " . implode(', ', $allowedRoles)); } return $next($request); } } // ───────────────────────────────────────────────────────────────────────────── // REGISTRATION (bootstrap/app.php — Laravel 11+) // ───────────────────────────────────────────────────────────────────────────── // $middleware->alias([ // 'role' => \App\Http\Middleware\EnsureUserHasRole::class, // ]); // ───────────────────────────────────────────────────────────────────────────── // USAGE — Chaining middleware on routes (routes/web.php) // ───────────────────────────────────────────────────────────────────────────── use Illuminate\Support\Facades\Route; use App\Http\Controllers\ArticleController; use App\Http\Controllers\AnalyticsController; // Chain: user must be logged in AND have a verified email AND have the 'editor' role. Route::get('/articles/create', [ArticleController::class, 'create']) ->middleware(['auth', 'verified', 'role:editor']) ->name('articles.create'); // Multiple roles allowed — editor OR moderator can access analytics. Route::get('/analytics', [AnalyticsController::class, 'index']) ->middleware(['auth', 'role:editor,moderator']) ->name('analytics.index'); // Route group with shared middleware — every route here needs auth + admin role. Route::middleware(['auth', 'role:admin']) ->prefix('admin') ->name('admin.') ->group(function () { Route::get('/settings', fn() => view('admin.settings'))->name('settings'); Route::get('/billing', fn() => view('admin.billing'))->name('billing'); });
// → ArticleController@create executes normally. ✓
// GET /articles/create as a logged-in, email-verified 'viewer':
// → HTTP 403: "Access requires one of: editor"
// GET /analytics as a logged-in 'moderator':
// → AnalyticsController@index executes normally. ✓
// GET /analytics as a logged-in 'viewer':
// → HTTP 403: "Access requires one of: editor, moderator"
Testing Middleware in Isolation — Don't Skip This Step
Most developers test middleware indirectly by hitting a route and checking the response code. That works, but it makes your tests fragile — a controller change can break a middleware test for no good reason. Laravel's testing toolkit lets you test middleware directly by faking the request and injecting it into the middleware's handle method. This keeps your middleware tests fast, focused, and immune to controller-level changes.
The cleaner approach for route-level tests is $this->actingAs($user) combined with ->get('/route') and asserting the HTTP status code. For middleware unit tests, you can also use Route::fake() or test the middleware class directly with a Request and a closure that captures whether $next was called.
Pair middleware tests with Laravel's RefreshDatabase trait when the middleware queries the database (like a subscription check), and avoid it for pure logic checks (like a role string comparison) to keep your suite fast. Good middleware tests document your security rules better than any comment ever could.
<?php // File: tests/Feature/Middleware/EnsureUserHasRoleTest.php // Run: php artisan test --filter=EnsureUserHasRoleTest namespace Tests\Feature\Middleware; use App\Models\User; use Illuminate\Foundation\Testing\RefreshDatabase; use Tests\TestCase; class EnsureUserHasRoleTest extends TestCase { use RefreshDatabase; // Resets the DB between tests — important for user creation. /** @test */ public function admin_user_can_access_admin_dashboard(): void { // Arrange: create a user with the 'admin' role using a factory state. $adminUser = User::factory()->create(['role' => 'admin']); // Act: simulate the admin making a GET request, fully authenticated. $response = $this->actingAs($adminUser)->get('/admin/dashboard'); // Assert: the request passed through middleware and hit the controller. $response->assertStatus(200); } /** @test */ public function editor_user_is_forbidden_from_admin_dashboard(): void { // Arrange: a regular editor — NOT an admin. $editorUser = User::factory()->create(['role' => 'editor']); // Act + Assert: expect the middleware to block with 403. $this->actingAs($editorUser) ->get('/admin/dashboard') ->assertStatus(403); } /** @test */ public function guest_is_redirected_to_login_from_admin_dashboard(): void { // No actingAs() — simulates an unauthenticated guest. $this->get('/admin/dashboard') ->assertRedirect('/login'); } /** @test */ public function middleware_allows_multiple_roles_when_configured(): void { // Arrange: a moderator hitting the analytics route which allows editor,moderator. $moderator = User::factory()->create(['role' => 'moderator']); $this->actingAs($moderator) ->get('/analytics') ->assertStatus(200); // Moderator is in the allowed list — should pass. } }
PASS Tests\Feature\Middleware\EnsureUserHasRoleTest
✓ admin user can access admin dashboard 0.18s
✓ editor user is forbidden from admin dashboard 0.09s
✓ guest is redirected to login from admin dashboard 0.08s
✓ middleware allows multiple roles when configured 0.10s
Tests: 4 passed (4 assertions)
Duration: 0.45s
Excluding Middleware and Route Group Tricks — withoutMiddleware() and Middleware Priority
Sometimes you need a route inside a group to skip a middleware that the group applies. For example, an admin group might have 'auth' and 'role:admin', but a public login route inside that group shouldn't require authentication. Laravel's ->withoutMiddleware() method lets you exclude specific middleware from a route or group.
withoutMiddleware() is also useful when you're testing a route in isolation and don't want any global middleware interfering. Combined with route groups, it gives you fine-grained control over which requests pass through which gates.
Another advanced trick: middleware priority. Laravel allows you to define a $middlewarePriority array in your HTTP kernel to force the order of middleware (e.g., always run StartSession before Authenticate). This is rarely needed but valuable when you have middleware that depends on another's side effects (like sessions).
<?php // routes/web.php use Illuminate\Support\Facades\Route; use App\Http\Controllers\Auth\LoginController; use App\Http\Controllers\DashboardController; // Admin group with auth and role:admin middleware Route::middleware(['auth', 'role:admin']) ->prefix('admin') ->group(function () { // This route will NOT have the 'auth' and 'role:admin' middleware applied Route::get('/public-info', function () { return 'Public admin info - no auth required'; })->withoutMiddleware(['auth', 'role:admin']); // Other routes in the group still use the group middleware Route::get('/dashboard', [DashboardController::class, 'index']); }); // ───────────────────────────────────────────────────────────────────────────── // withoutMiddleware() also works for individual middleware: // Route::get('/some-route', ...)->withoutMiddleware('auth'); // // ───────────────────────────────────────────────────────────────────────────── // Middleware Priority in app/Http/Kernel.php (Laravel 10+): // // protected $middlewarePriority = [ // \Illuminate\Session\Middleware\StartSession::class, // \Illuminate\View\Middleware\ShareErrorsFromSession::class, // \App\Http\Middleware\Authenticate::class, // \Illuminate\Routing\Middleware\SubstituteBindings::class, // // ... // ]; // // This forces StartSession to always run before Authenticate, even if the order in the route middleware array is different.
// (no authentication required, passes through without the group middleware)
// GET /admin/dashboard → requires authentication and admin role
// (group middleware applies normally)
| Aspect | Global Middleware | Route / Group Middleware |
|---|---|---|
| Runs on | Every single HTTP request, no exceptions | Only routes where you explicitly apply it |
| Registered in | $middleware array in Kernel.php / withMiddleware() | $middlewareAliases / ->middleware() on route |
| Best for | CORS headers, maintenance mode, request trimming | Auth checks, role guards, subscription checks |
| Performance impact | Paid on every request — keep it lean | Scoped cost — only hits relevant routes |
| Typical examples | TrimStrings, ConvertEmptyStringsToNull, TrustProxies | auth, verified, throttle, your custom role guard |
| Can accept parameters | No — runs unconditionally, no context to pass | Yes — middleware('role:admin') syntax fully supported |
| Execution order | Runs before route middleware in the pipeline | Runs after global middleware, in declaration order |
🎯 Key Takeaways
- Code written BEFORE $next($request) runs on the request going IN — use it for auth, rate limiting, and input checks. Code written AFTER runs on the response coming OUT — use it for headers, logging, and caching.
- Middleware parameters (middleware('role:editor,moderator')) let one class handle dynamic rules, eliminating the need for a separate middleware class per role or permission level.
- Middleware chain order is security-critical: always run 'auth' before any authorisation middleware that calls Auth::user(), or you'll get null pointer errors or silent security failures.
- Test middleware at the HTTP contract level with actingAs() — asserting the correct status codes (200, 403, redirect) proves both that the middleware logic is correct AND that it's registered on the right routes.
- Middleware can be excluded from specific routes using withoutMiddleware(), but use it sparingly and document the exclusion to avoid accidental security gaps.
⚠ Common Mistakes to Avoid
Interview Questions on This Topic
- QCan you explain what the Laravel middleware pipeline is and how a request moves through it? What happens to the response on the way back out?Mid-levelReveal
- QWhat is the difference between before middleware and after middleware in Laravel? Give a real use case for each.Mid-levelReveal
- QIf you register a middleware alias and apply it to a route group, but one route inside that group needs to skip the middleware, how would you handle that?SeniorReveal
- QHow do you pass parameters to middleware in Laravel? Can you pass multiple parameters?Mid-levelReveal
- QWhat is the $middlewarePriority array and when would you use it?SeniorReveal
Frequently Asked Questions
How do I create custom middleware in Laravel?
Run php artisan make:middleware YourMiddlewareName. This creates a class in app/Http/Middleware/ with a handle(Request $request, Closure $next) method. Write your logic in that method, then register the class in bootstrap/app.php (Laravel 11+) or app/Http/Kernel.php (Laravel 10) and assign it an alias to use in your route definitions.
What is the difference between global middleware and route middleware in Laravel?
Global middleware runs on every single HTTP request your application receives, regardless of which route is hit — things like trimming whitespace from inputs or setting CORS headers. Route middleware is opt-in: you explicitly attach it to specific routes or route groups, so it only runs when those routes are matched. Put security-critical, route-specific logic in route middleware to avoid unnecessary overhead on every request.
Can Laravel middleware modify the response, or does it only inspect the request?
It can do both. By placing code before $next($request) you inspect or modify the incoming request. By placing code after $next($request) — on the returned $response object — you can add headers, log response data, compress output, or even replace the response entirely. This 'before and after' dual capability is what makes middleware so powerful for cross-cutting concerns.
Can I apply middleware to all routes except a few?
Yes, you can use withoutMiddleware() on specific routes to exclude them from group or route middleware. However, this does not work for global middleware. You can also conditionally apply middleware inside the handle method by checking the route name or URL pattern, but that couples the middleware to route details. The recommended approach for many exceptions is to apply middleware to individual routes rather than to a group.
How do I test that middleware is correctly registered and enforced?
The best way is to write feature tests that simulate requests to routes with the middleware applied. Use actingAs() to authenticate users with different roles, then assert the expected HTTP status codes (200, 403, redirect). Also test guest access. This verifies that the middleware is both logically correct and registered on the intended routes. You can also unit test the handle method directly, but feature tests catch registration issues.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.