Laravel Middleware Order: Auth Before Role Prevents 403
Production bug: all users (including admins) got 403 when role middleware ran before auth.
20+ years shipping production PHP systems at scale. Everything here is grounded in real deployments.
- ✓Solid grasp of fundamentals
- ✓Comfortable reading code examples
- ✓Basic production concepts
- 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
Picture a busy nightclub. Before you reach the dance floor, you pass a bouncer who checks your ID, a coat-check who takes your jacket, and a staff member who stamps your hand. Each person does one job, in order, before you get inside. Laravel middleware is exactly that — a series of checkpoints every HTTP request must pass through before it ever touches your controller. If any checkpoint says 'no', the request never gets in.
| Chrome | Firefox | Safari | Edge |
|---|---|---|---|
| ✓ | ✓ | ✓ | ✓ |
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.
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.
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.
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.
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).
Middleware Execution Order: Why Your Filters Run in the Wrong Sequence
Most tutorials show you how to create middleware but never explain order. This is where bugs breed. Laravel runs middleware in a stack. Global middleware fires first, then route-specific middleware (in the order you assign them), and finally controller middleware. But here’s the kicker: the response runs in reverse. If your logging middleware runs before auth, you’ll log requests that fail authentication. Learn to control priority. In app/Http/Kernel.php, the $middlewarePriority array lets you reorder global middleware. I’ve seen teams spend hours debugging CORS issues caused by middleware order. Always put EncryptCookies before StartSession. Put Cors before ThrottleRequests if you need to return CORS headers on throttled responses. Test order explicitly in your route files. Don’t assume the stack is default-safe.
ThrottleRequests run before HandleCors — browsers blocked the 429 response because CORS headers were missing. Reorder saved the feature.Terminable Middleware: Run Cleanup After the Response Ships
Most middleware runs before or after the controller. But what about code that should execute after the response is already sent to the browser? Laravel’s terminable middleware runs during kernel termination. Use it for writing audit logs, flushing cache, or sending analytics without blocking the user. Define a terminate method on any middleware. Laravel calls it after the response is sent. This is not async — it’s synchronous but happens after headers are flushed. Perfect for heavy operations like image resizing or slow API calls. Benchmark your routes. If a middleware takes 200ms and you move it to terminate, the user sees the page in real-time while the server finishes work. Think of it as a deferred payload. I use this in payment systems to log receipts without slowing checkout.
Trusted Proxy Middleware: How to Stop Breaking Redirects Behind Load Balancers
Every Laravel app behind a proxy (Nginx, AWS ELB, Cloudflare) needs TrustProxies middleware. Without it, sends users to redirect()->back()http://localhost instead of your domain. The helper generates wrong scheme (http vs https). I’ve seen teams blame Laravel for broken login redirects when the fix was a single line. Open url()App\Http\Middleware\TrustProxies. Set $proxies to '*' if you trust all proxies (like Cloudflare), or list specific IPs. Configure $headers to Request::HEADER_X_FORWARDED_TRAFO for AWS. Test with dd(url('home')) before deploying. A misconfigured proxy middleware creates silent security holes — cookie theft via mixed content warnings. Trust me, this is the most overlooked middleware in production.
$proxies = '*' on shared hosting you don’t fully control. An attacker could spoof headers and bypass IP-based rate limiting. On AWS, set $proxies = [env('AWS_ELB_IP')] or use \Symfony\Component\HttpFoundation\Request::setTrustedProxies with a whitelist.Laravel 11 Middleware Simplification
Laravel 11 introduces a streamlined middleware configuration that reduces boilerplate. Instead of defining middleware in app/Http/Kernel.php, you now register middleware directly in the bootstrap/app.php file using the ->withMiddleware() method. This change simplifies the bootstrap process and makes middleware registration more intuitive.
For example, to register global middleware in Laravel 11:
// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
// ...
)
->withMiddleware(function (Middleware $middleware) {
$middleware->append(\App\Http\Middleware\TrustProxies::class);
})
->withExceptions(function ($exceptions) {
// ...
})->create();
Route-specific middleware groups are also defined here. For instance, to add middleware to the 'web' group:
->withMiddleware(function (Middleware $middleware) {
$middleware->web(append: [
EnsureUserIsSubscribed::class,
]);
})
This approach eliminates the need for a separate Kernel class, reducing cognitive load and potential misconfiguration. The Middleware class provides fluent methods like append, prepend, remove, and replace for managing middleware stacks.
Additionally, Laravel 11 removes several default middleware that were previously included in the web and api groups, such as TrimStrings and ConvertEmptyStringsToNull. You can re-add them if needed, but the defaults are now minimal, giving you more control.
This simplification is particularly beneficial for new projects, but existing projects upgrading to Laravel 11 can adopt it gradually. The old Kernel-based approach still works, but the new method is recommended for clarity and future compatibility.
TrimStrings may affect input handling. Test thoroughly in staging before deploying.bootstrap/app.php with a fluent API, eliminating the need for a separate Kernel class.TrustProxy and LoadBalancer Middleware for Production
When your Laravel application runs behind a load balancer or reverse proxy (like Nginx, AWS ELB, or HAProxy), the server's IP address is often the proxy's IP, not the client's. This breaks features like rate limiting, logging, and redirects that rely on the client's IP or scheme.
Laravel's TrustProxies middleware solves this by trusting specific proxies to forward accurate headers. In Laravel 11, you configure it in bootstrap/app.php:
``php ->withMiddleware(function (Middleware $middleware) { $middleware->trustProxies(at: '*'); }) ``
For more control, specify trusted proxies:
``php $middleware->trustProxies( proxies: ['192.168.1.1', '10.0.0.0/8'], headers: Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO ); ``
In older Laravel versions, you set $proxies in app/Http/Middleware/TrustProxies.php. The * wildcard trusts all proxies, but be cautious—only use it if your proxy is fully trusted (e.g., in a private network).
TrustProxiesgenerates HTTP instead of HTTPS.url()->route()returns the proxy IP.request()->ip()- Rate limiting based on IP fails.
Always test behind your load balancer. Use php artisan serve locally, but simulate proxy headers with tools like ngrok or by setting X-Forwarded-* headers in your local environment.
For production, explicitly list proxy IPs or use a CIDR range. Avoid trusting all proxies (*) on public-facing apps unless you have strict network controls.
TrustProxies explicitly in production. Use a CIDR range for your load balancer's IPs. Test with actual proxy headers to avoid surprises.TrustProxies middleware ensures correct client IP, scheme, and host detection behind load balancers, preventing broken redirects and rate limiting issues.Rate Limiter Middleware in Laravel
Laravel's rate limiter middleware protects your application from abuse by limiting the number of requests a user or IP can make in a given time window. It's built on the Illuminate\Cache\RateLimiter class and integrates seamlessly with the framework.
Define rate limiters in App\Providers\AppServiceProvider using the RateLimiter facade:
```php use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter;
RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip()); }); ```
Then apply the throttle middleware to routes:
``php Route::middleware('throttle:api')->group(function () { Route::get('/user', function () { // ... }); }); ``
You can also use named limiters directly:
``php Route::middleware(['throttle:api'])->get('/user', function () { // }); ``
For more granular control, create multiple limiters:
```php RateLimiter::for('uploads', function (Request $request) { return Limit::perMinute(5)->by($request->user()->id); });
RateLimiter::for('login', function (Request $request) { return Limit::perMinute(3)->by($request->ip()); }); ```
Apply them:
```php Route::post('/upload', function () { // })->middleware('throttle:uploads');
Route::post('/login', function () { // })->middleware('throttle:login'); ```
Rate limiters can return dynamic limits based on user roles or other conditions. For example, premium users get higher limits:
``php RateLimiter::for('api', function (Request $request) { $limit = $request->user()?->isPremium() ? 100 : 20; return Limit::perMinute($limit)->by($request->user()?->id ?: $request->ip()); }); ``
When a request exceeds the limit, Laravel returns a 429 Too Many Requests response with Retry-After header. You can customize the response by throwing a ThrottleRequestsException or using the throttle middleware's callback.
Rate limiting is essential for production APIs to prevent brute force attacks and ensure fair usage. Always test your limits under load.
Middleware Order Bug Takes Down Admin Panel in Production
- Always place authentication middleware before authorization middleware in the chain.
- Test middleware order with a guest user first: if guest gets 403 instead of redirect, order is wrong.
- Use a logging middleware to trace the pipeline execution order during debugging.
redirect()->back() for unauthorized access to prevent infinite loop.php artisan route:list | grep <route>Check middleware aliases in bootstrap/app.php| File | Command / Code | Purpose |
|---|---|---|
| RequestLifecycleDemo.php | namespace App\Http\Middleware; | What Laravel Middleware Actually Does Under the Hood |
| EnsureUserIsAdmin.php | namespace App\Http\Middleware; | Creating Real-World Middleware |
| EnsureUserHasRole.php | namespace App\Http\Middleware; | Middleware Parameters and Chaining |
| EnsureUserHasRoleTest.php | namespace Tests\Feature\Middleware; | Testing Middleware in Isolation |
| withoutMiddleware_example.php | use Illuminate\Support\Facades\Route; | Excluding Middleware and Route Group Tricks |
| app | protected $middlewarePriority = [ | Middleware Execution Order |
| app | namespace App\Http\Middleware; | Terminable Middleware |
| app | namespace App\Http\Middleware; | Trusted Proxy Middleware |
| bootstrap | return Application::configure(basePath: dirname(__DIR__)) | Laravel 11 Middleware Simplification |
| bootstrap | ->withMiddleware(function (Middleware $middleware) { | TrustProxy and LoadBalancer Middleware for Production |
| app | use Illuminate\Cache\RateLimiting\Limit; | Rate Limiter Middleware in Laravel |
Key takeaways
Interview Questions on This Topic
Can 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?
Frequently Asked Questions
20+ years shipping production PHP systems at scale. Everything here is grounded in real deployments.
That's Laravel. Mark it forged?
8 min read · try the examples if you haven't