Middleware Guide
Last Updated: 2026-01-08 Status: Active Audience: Developers
This guide documents middleware patterns and conventions for the Client Portal application.
Table of Contents
- Overview
- Creating Middleware
- Registering Middleware
- Common Middleware
- Middleware Parameters
- Middleware Groups
- Testing
Overview
Middleware filters HTTP requests entering your application:
- Authentication checks
- Authorization/role verification
- Request logging
- Rate limiting
- Response modification
Request Lifecycle
Request → Global Middleware → Route Middleware → Controller → Response
↓
Request ← Global Middleware ← Route Middleware ← Controller ← Response
Creating Middleware
Generate Middleware
php artisan make:middleware EnsureClientActive
php artisan make:middleware LogRequestResponse
Basic Middleware Structure
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureClientActive
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
// Before the request is handled
if (!$request->user()?->client?->isActive()) {
abort(403, 'Your account is not active.');
}
// Pass to next middleware/controller
return $next($request);
}
}
Before & After Middleware
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class LogRequestResponse
{
/**
* Handle an incoming request.
*/
public function handle(Request $request, Closure $next): Response
{
// BEFORE: Log incoming request
$startTime = microtime(true);
Log::info('Incoming request', [
'method' => $request->method(),
'url' => $request->fullUrl(),
'ip' => $request->ip(),
]);
// Process request
$response = $next($request);
// AFTER: Log response
$duration = round((microtime(true) - $startTime) * 1000, 2);
Log::info('Outgoing response', [
'status' => $response->getStatusCode(),
'duration_ms' => $duration,
]);
return $response;
}
}
Terminable Middleware
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class TrackAnalytics
{
public function handle(Request $request, Closure $next): Response
{
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
// Good for logging, analytics, cleanup
Log::channel('analytics')->info('Page view', [
'url' => $request->fullUrl(),
'user_id' => $request->user()?->id,
'status' => $response->getStatusCode(),
]);
}
}
Registering Middleware
Global Middleware (Laravel 11+)
// bootstrap/app.php
use App\Http\Middleware\TrackAnalytics;
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
// Append to global middleware
$middleware->append(TrackAnalytics::class);
// Prepend to run first
$middleware->prepend(SetSecurityHeaders::class);
})
->create();
Route Middleware Aliases
// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware->alias([
'admin' => \App\Http\Middleware\EnsureUserIsAdmin::class,
'client.active' => \App\Http\Middleware\EnsureClientActive::class,
'project.access' => \App\Http\Middleware\EnsureProjectAccess::class,
]);
})
->create();
Using in Routes
// Single middleware
Route::get('/admin/dashboard', [DashboardController::class, 'index'])
->middleware('admin');
// Multiple middleware
Route::get('/projects/{project}', [ProjectController::class, 'show'])
->middleware(['auth', 'client.active', 'project.access']);
// Middleware group
Route::middleware(['auth', 'admin'])->group(function () {
Route::resource('clients', ClientController::class);
Route::resource('invoices', InvoiceController::class);
});
// Exclude middleware
Route::resource('clients', ClientController::class)
->middleware('auth')
->withoutMiddleware('throttle');
Controller Middleware
<?php
namespace App\Http\Controllers;
use App\Http\Middleware\EnsureClientActive;
use Illuminate\Routing\Controllers\HasMiddleware;
use Illuminate\Routing\Controllers\Middleware;
class ProjectController extends Controller implements HasMiddleware
{
public static function middleware(): array
{
return [
'auth',
new Middleware('client.active', except: ['index', 'show']),
new Middleware('throttle:60,1', only: ['store', 'update']),
];
}
}
Common Middleware
Admin Check
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserIsAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->user()?->is_admin) {
if ($request->expectsJson()) {
return response()->json(['message' => 'Unauthorized.'], 403);
}
abort(403, 'Access denied.');
}
return $next($request);
}
}
Role-Based Access
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserHasRole
{
public function handle(Request $request, Closure $next, string ...$roles): Response
{
$user = $request->user();
if (!$user || !$user->hasAnyRole($roles)) {
if ($request->expectsJson()) {
return response()->json(['message' => 'Insufficient permissions.'], 403);
}
abort(403, 'You do not have permission to access this resource.');
}
return $next($request);
}
}
// Usage: Route::get('/reports', ...)->middleware('role:admin,manager');
Resource Owner Check
<?php
namespace App\Http\Middleware;
use App\Models\Client;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureClientOwner
{
public function handle(Request $request, Closure $next): Response
{
$client = $request->route('client');
if ($client instanceof Client) {
$user = $request->user();
// Admin can access all clients
if ($user->is_admin) {
return $next($request);
}
// Client user can only access their own client
if ($user->client_id !== $client->id) {
abort(403, 'You do not have access to this client.');
}
}
return $next($request);
}
}
Security Headers
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SetSecurityHeaders
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Frame-Options', 'SAMEORIGIN');
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
if (config('app.env') === 'production') {
$response->headers->set(
'Strict-Transport-Security',
'max-age=31536000; includeSubDomains'
);
}
return $response;
}
}
Force HTTPS
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class ForceHttps
{
public function handle(Request $request, Closure $next): Response
{
if (!$request->secure() && app()->environment('production')) {
return redirect()->secure($request->getRequestUri());
}
return $next($request);
}
}
Last Activity Tracking
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class TrackLastActivity
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($user = $request->user()) {
$user->updateQuietly(['last_active_at' => now()]);
}
return $response;
}
}
Locale Detection
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Symfony\Component\HttpFoundation\Response;
class SetLocale
{
public function handle(Request $request, Closure $next): Response
{
// Priority: User preference > Session > Browser > Default
$locale = $request->user()?->locale
?? session('locale')
?? $request->getPreferredLanguage(['en', 'es', 'fr'])
?? config('app.locale');
App::setLocale($locale);
return $next($request);
}
}
Middleware Parameters
Single Parameter
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CheckAge
{
public function handle(Request $request, Closure $next, int $minAge): Response
{
if ($request->user()->age < $minAge) {
abort(403, "You must be at least {$minAge} years old.");
}
return $next($request);
}
}
// Route::get('/adult-content', ...)->middleware('check.age:18');
Multiple Parameters
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureUserHasPermission
{
public function handle(Request $request, Closure $next, string ...$permissions): Response
{
foreach ($permissions as $permission) {
if (!$request->user()->hasPermission($permission)) {
abort(403, "Missing required permission: {$permission}");
}
}
return $next($request);
}
}
// Route::get('/reports', ...)->middleware('permission:view-reports,export-data');
Named Parameters
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class CacheResponse
{
public function handle(
Request $request,
Closure $next,
int $minutes = 60,
string $key = null
): Response {
$cacheKey = $key ?? 'response:' . sha1($request->fullUrl());
if (Cache::has($cacheKey)) {
return Cache::get($cacheKey);
}
$response = $next($request);
Cache::put($cacheKey, $response, now()->addMinutes($minutes));
return $response;
}
}
// Route::get('/api/stats', ...)->middleware('cache:30');
// Route::get('/api/report', ...)->middleware('cache:60,report-cache-key');
Middleware Groups
Defining Groups
// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware->group('admin', [
'auth',
\App\Http\Middleware\EnsureUserIsAdmin::class,
\App\Http\Middleware\TrackAdminActivity::class,
]);
$middleware->group('client', [
'auth',
\App\Http\Middleware\EnsureClientActive::class,
\App\Http\Middleware\TrackClientActivity::class,
]);
$middleware->group('api', [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
]);
})
->create();
Using Groups in Routes
// routes/web.php
Route::middleware('admin')->prefix('admin')->name('admin.')->group(function () {
Route::resource('clients', Admin\ClientController::class);
Route::resource('invoices', Admin\InvoiceController::class);
Route::resource('users', Admin\UserController::class);
});
Route::middleware('client')->prefix('portal')->name('client.')->group(function () {
Route::get('dashboard', [Client\DashboardController::class, 'index']);
Route::resource('projects', Client\ProjectController::class)->only(['index', 'show']);
Route::resource('invoices', Client\InvoiceController::class)->only(['index', 'show']);
});
Modifying Built-in Groups
// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
// Add to web group
$middleware->web(append: [
\App\Http\Middleware\SetLocale::class,
]);
// Add to API group
$middleware->api(prepend: [
\App\Http\Middleware\JsonResponseMiddleware::class,
]);
// Remove from web group
$middleware->web(remove: [
\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
]);
})
->create();
Testing
Testing Middleware Directly
<?php
namespace Tests\Unit\Middleware;
use App\Http\Middleware\EnsureUserIsAdmin;
use App\Models\User;
use Illuminate\Http\Request;
use Tests\TestCase;
class EnsureUserIsAdminTest extends TestCase
{
public function test_admin_user_can_pass(): void
{
$user = User::factory()->admin()->create();
$request = Request::create('/admin/dashboard', 'GET');
$request->setUserResolver(fn () => $user);
$middleware = new EnsureUserIsAdmin();
$response = $middleware->handle($request, fn ($req) => response('OK'));
$this->assertEquals('OK', $response->getContent());
}
public function test_non_admin_user_is_blocked(): void
{
$user = User::factory()->create(['is_admin' => false]);
$request = Request::create('/admin/dashboard', 'GET');
$request->setUserResolver(fn () => $user);
$middleware = new EnsureUserIsAdmin();
$this->expectException(\Symfony\Component\HttpKernel\Exception\HttpException::class);
$middleware->handle($request, fn ($req) => response('OK'));
}
}
Testing Routes with Middleware
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AdminAccessTest extends TestCase
{
use RefreshDatabase;
public function test_admin_can_access_admin_routes(): void
{
$admin = User::factory()->admin()->create();
$response = $this->actingAs($admin)->get('/admin/dashboard');
$response->assertOk();
}
public function test_non_admin_cannot_access_admin_routes(): void
{
$user = User::factory()->create(['is_admin' => false]);
$response = $this->actingAs($user)->get('/admin/dashboard');
$response->assertForbidden();
}
public function test_guest_is_redirected_to_login(): void
{
$response = $this->get('/admin/dashboard');
$response->assertRedirect('/login');
}
}
Disabling Middleware in Tests
use Illuminate\Foundation\Testing\WithoutMiddleware;
class ExampleTest extends TestCase
{
use WithoutMiddleware;
public function test_without_middleware(): void
{
// All middleware is bypassed
$response = $this->get('/admin/dashboard');
$response->assertOk();
}
}
// Or for specific tests
public function test_specific_without_middleware(): void
{
$response = $this->withoutMiddleware()
->get('/admin/dashboard');
$response->assertOk();
}
// Skip specific middleware
public function test_skip_specific_middleware(): void
{
$response = $this->withoutMiddleware([
\App\Http\Middleware\EnsureUserIsAdmin::class,
])->get('/admin/dashboard');
}
Best Practices
Do
- Keep middleware focused on single responsibility
- Use descriptive middleware names
- Return appropriate responses for JSON/HTML
- Use middleware groups for common combinations
- Test middleware in isolation and integration
- Use terminable middleware for post-response tasks
Don't
- Put complex business logic in middleware
- Forget to handle both web and API responses
- Create middleware with side effects on GET requests
- Use middleware when policies are more appropriate
- Skip middleware parameter validation
Middleware vs Policies
| Use Middleware | Use Policy |
|---|---|
| Authentication | Model authorization |
| Rate limiting | "Can user edit this post?" |
| Request logging | Complex permission logic |
| Headers/CORS | Resource-specific checks |
| Locale/timezone | Gate checks |