Laravel Middleware: A Complete Guide
Middleware is one of the most powerful features in Laravel, acting as a filtering mechanism for HTTP requests entering your application. Whether you need to authenticate users, log requests, modify responses, or implement custom security measures, middleware provides an elegant, reusable way to handle these cross-cutting concerns.
In this comprehensive guide, we'll explore everything about Laravel middleware—from understanding the basics to implementing advanced patterns that will make your applications more secure, maintainable, and efficient.
Middleware is the gatekeeping layer of your Laravel application. Understanding how to create and use middleware effectively separates beginner developers from those who build robust, production-ready applications.
Understanding the Middleware Pipeline
Before diving into code, let's visualize how middleware works in Laravel. Think of middleware as a series of "layers" that wrap around your application. Each HTTP request must pass through these layers before reaching 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
Laravel makes creating middleware incredibly simple with the Artisan command:
php artisan make:middleware CheckAge
This creates a new middleware class in app/Http/Middleware. Let's look at the basic structure:
<?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 crucial—it 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
Middleware can perform actions before OR after the request is handled. Here's the difference:
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;
}
Perform actions both before and after:
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
After creating middleware, you need to register it. Laravel 11 simplified this process significantly. Here's how to register middleware:
<?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
Once registered, you can apply middleware to routes in several ways:
<?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 accept parameters, making them incredibly flexible. This is perfect for role-based access control:
<?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.');
}
}
Use it in your routes with parameters 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
Let's explore some real-world middleware implementations you'll commonly need:
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 Best Practice
Apply SecurityHeaders middleware globally to ensure all responses include proper security headers. This protects against clickjacking, XSS, and other common attacks.
Terminable Middleware
Sometimes you need to perform actions after the response has been sent to the browser. Terminable middleware handles this with the terminate method:
<?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
// Perfect 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
Controller Middleware
You can also apply middleware directly within controllers for more granular control:
<?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
Sometimes the order in which middleware runs matters. Laravel allows you to define priority:
->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,
]);
})
Middleware priority only affects the order when multiple middleware are applied. Session middleware should always run before authentication, and authentication before 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,
]);
})
Now use your custom group in routes:
// 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
Testing middleware is crucial to ensure your security and validation logic works correctly:
<?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
Each middleware should do one thing well. Avoid creating "god" middleware that handles authentication, logging, and response transformation all at once.
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. Forgetting this causes hard-to-debug issues.
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
The terminate method is great for non-blocking operations, but remember it still runs synchronously. For truly async tasks, dispatch jobs to queues.
Conclusion
Laravel middleware is a powerful mechanism for filtering HTTP requests and responses. By mastering middleware, you can:
- Implement robust authentication and authorization
- Add security headers and CORS handling
- Log and monitor requests effectively
- Transform API responses consistently
- Rate limit endpoints to prevent abuse
- Keep your controllers clean and focused
The key to effective middleware is keeping each piece focused and composable. Build small, reusable middleware that can be combined in different ways across your routes. This approach leads to maintainable, testable code that's easy to reason about.
Start with the built-in Laravel middleware, understand how they work, and gradually build your own as your application's needs grow. Happy coding!
