Skip to main content
Back to ScopeForged

ScopeForged Documentation

Technical documentation, guides, and feature references for the ScopeForged client portal.

Architecture Patterns/Middleware

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

  1. Overview
  2. Creating Middleware
  3. Registering Middleware
  4. Common Middleware
  5. Middleware Parameters
  6. Middleware Groups
  7. 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 MiddlewareUse Policy
AuthenticationModel authorization
Rate limiting"Can user edit this post?"
Request loggingComplex permission logic
Headers/CORSResource-specific checks
Locale/timezoneGate checks