Skip to main content

Command Palette

Search for a command to run...

Laravel Middleware Like a Pro: Real-World Patterns for Auth, Roles, and Scoping

Updated
6 min read
Laravel Middleware Like a Pro: Real-World Patterns for Auth, Roles, and Scoping

Laravel Middleware Like a Pro: Real-World Patterns for Auth, Roles, and Scoping

Real talk: I built a multi-tenant SaaS backend for a multi-tenant SaaS platform with complex role-based access control. Three middleware patterns saved the day: CheckRole (simple RBAC), SchoolAdminScope (tenant scoping), and AuditMiddleware (logging everything). Here's how they work.


Middleware Patterns Architecture


The Problem: Multi-Tenancy + RBAC = Headache

We're building a multi-tenant SaaS platform — a education SaaS platform that:

  • Manages multiple schools
  • Has different user roles (super_admin, school_admin, PEO, staff, learner)
  • Needs strict data isolation between schools

On paper, it sounds simple. In practice?

// The nightmare scenario — how do we handle this?
$user = User::find(123); // From school A
$peo = PEO::find(456);   // Assigned to school B

// Can $peo access $user's data? YES or NO?
// Can $user access $peo's analytics?
// Where do we draw the line?

We needed:

  1. Role-based access control — which endpoints can which roles access?
  2. Tenant scoping — school admins can ONLY see their school's data
  3. Audit logging — track every mutation for compliance

That's where middleware saved us.


Pattern 1: CheckRole — Simple RBAC

Use when: You need to enforce role permissions on endpoints.

Implementation:

Middleware Code Examples

// app/Http/Middleware/CheckRole.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class CheckRole
{
    public function handle(Request $request, Closure $next, string ...$roles): Response
    {
        if (! auth()->check()) {
            return response()->json([
                'success' => false,
                'message' => 'Unauthorized',
            ], 401);
        }

        $user = auth()->user();
        $hasRequiredRole = in_array($user->role, $roles);

        if (! $hasRequiredRole) {
            return response()->json([
                'success' => false,
                'message' => 'Insufficient permissions',
            ], 403);
        }

        return $next($request);
    }
}

Usage in routes:

// Only super_admin can access dashboard
Route::middleware(['auth', 'check_role:super_admin'])
    ->get('/api/v1/super-admin/dashboard', [SuperAdminController::class, 'dashboard']);

// School admins can manage staff
Route::middleware(['auth', 'check_role:school_admin'])
    ->post('/api/v1/school-admin/staff/invite', [SchoolAdminController::class, 'invite']);

// PEOs can see outreach stats
Route::middleware(['auth', 'check_role:peo'])
    ->get('/api/v1/peos/{id}/outreach', [PeoController::class, 'outreachStats']);

Why this works:

  • Declarative — routes clearly show required roles
  • Reusable — one middleware handles all role checks
  • Fast — single query (user is already loaded from auth guard)

Pattern 2: SchoolAdminScope — Tenant Scoping

Use when: You need to enforce tenant isolation (multi-tenancy).

The Problem: School admins could access other schools' data if they know the IDs.

// BAD — school_admin can access ANY school
$otherSchool = School::find(999);
$users = $otherSchool->users; // Security breach!

Solution:

// app/Http/Middleware/SchoolAdminScope.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class SchoolAdminScope
{
    public function handle(Request $request, Closure $next): Response
    {
        if (! auth()->check() || auth()->user()->role !== 'school_admin') {
            return $next($request);
        }

        $user = auth()->user();
        $schoolId = $request->route('school_id'); // From route parameter

        // Runtime check — ensure user belongs to this school
        if ($user->school_id !== $schoolId) {
            return response()->json([
                'success' => false,
                'message' => 'You can only access your school data',
            ], 403);
        }

        return $next($request);
    }
}

Usage in routes:

// School admins can ONLY access their school's staff
Route::middleware(['auth', 'school_admin'])
    ->get('/api/v1/schools/{school_id}/staff', [SchoolAdminController::class, 'staff']);

// School admins can ONLY see their own courses
Route::middleware(['auth', 'school_admin'])
    ->get('/api/v1/schools/{school_id}/courses', [CourseController::class, 'index']);

Why this works:

  • Layered security — route-level AND runtime checks
  • Hard to bypass — can't just change school_id in request
  • Clear error messages — "You can only access your school data"

Pattern 3: AuditMiddleware — Logging Everything

Use when: You need compliance logging (GDPR, compliance requirements).

The Implementation:

// app/Http/Middleware/AuditMiddleware.php
namespace App\Http\Middleware;

use App\Services\V1\AuditService;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;

class AuditMiddleware
{
    public function __construct(
        private AuditService $auditService,
    ) {}

    public function handle(Request $request, Closure $next): Response
    {
        if (str_starts_with($request->path(), 'api/v1/')) {
            // Don't audit health checks, swagger docs, etc.
            if ($request->route()?->getName() === 'api.docs') {
                return $next($request);
            }

            // Store request data before processing
            $request->attributes->set('audit_data', [
                'method' => $request->method(),
                'path' => $request->path(),
                'ip' => $request->ip(),
                'user_id' => auth()->id(),
                'role' => auth()->user()?->role,
                'school_id' => auth()->user()?->school_id,
                'request_body' => $request->except(['password', 'token']),
            ]);

            $response = $next($request);

            // Log response
            $this->auditService->log(
                "api.{$request->method()}",
                auth()->user(),
                [
                    'path' => $request->path(),
                    'status' => $response->status(),
                    'response_time_ms' => $request->server('REQUEST_TIME_FLOAT'),
                ]
            );

            return $response;
        }

        return $next($request);
    }
}

AuditService Implementation:

// app/Services/V1/AuditService.php
namespace App\Services\V1;

use App\Models\AuditLog;
use Illuminate\Support\Facades\DB;

class AuditService
{
    public function log(string $action, $user, array $data = []): AuditLog
    {
        return AuditLog::create([
            'user_id' => $user->id ?? null,
            'role' => $user->role ?? null,
            'school_id' => $user->school_id ?? null,
            'action' => $action,
            'ip_address' => request()->ip(),
            'request_path' => request()->path(),
            'request_method' => request()->method(),
            'payload' => json_encode($data),
            'user_agent' => request()->userAgent(),
        ]);
    }
}

Usage:

// Routes automatically log everything
Route::middleware(['auth', 'audit'])
    ->post('/api/v1/schools/{id}', [SchoolController::class, 'update']);

// Automatic logs created for:
// - Who made the request (user_id, role, school_id)
// - What was changed (from request payload)
// - When (created_at)
// - Where (IP address, user agent)

Why this works:

  • Zero friction — middleware handles it automatically
  • Compliance-ready — full audit trail
  • Queryable — can filter by user, school, action type

Putting It All Together

Our middleware stack in practice:

// app/Http/Kernel.php (or routes/api.php)
Route::middleware(['auth', 'check_role:super_admin'])
    ->group(function () {
        // Super admin routes
        Route::get('/api/v1/super-admin/schools', [SuperAdminController::class, 'index']);
        Route::post('/api/v1/super-admin/licences', [LicenceController::class, 'store']);
    });

Route::middleware(['auth', 'school_admin'])
    ->group(function () {
        // School admin routes — automatically scoped to their school
        Route::get('/api/v1/schools/{school_id}/staff', [SchoolAdminController::class, 'staff']);
        Route::post('/api/v1/schools/{school_id}/courses', [CourseController::class, 'store']);
    });

Route::middleware(['auth', 'check_role:school_admin', 'audit'])
    ->group(function () {
        // Audit logs automatically created for all school admin mutations
        Route::put('/api/v1/schools/{school_id}/name', [SchoolController::class, 'update']);
    });

Route::middleware(['auth', 'audit'])
    ->group(function () {
        // All API endpoints (except health checks) are audited
        Route::get('/api/v1/courses', [CourseController::class, 'index']);
        Route::post('/api/v1/seats/assign', [SeatService::class, 'assign']);
    });

The result:

  • ✅ Super admins have full access to everything
  • ✅ School admins can ONLY modify their school's data
  • ✅ All mutations are logged for compliance
  • ✅ No hardcoded business logic in controllers

Pro Tips

1. Use Route Model Binding for Tenant IDs

// Before
Route::middleware(['auth', 'school_admin'])
    ->get('/api/v1/schools/{school_id}/courses', [CourseController::class, 'index']);

// After
Route::middleware(['auth', 'school_admin'])
    ->get('/api/v1/schools/{school}/courses', [CourseController::class, 'index']);

// SchoolAdminScope middleware now gets $school object
public function handle(Request $request, Closure $next, School $school): Response
{
    if (auth()->user()->school_id !== $school->id) {
        return response()->json(['message' => 'Forbidden'], 403);
    }
    return $next($request);
}

2. Cache Role Checks (Performance)

class CheckRole extends Middleware
{
    public function handle(Request $request, Closure $next, string ...$roles)
    {
        // User is already authenticated
        // Role is stored in database column (fast query)
        // No caching needed for simple RBAC
        return parent::handle($request, $next, ...$roles);
    }
}

3. Document Middleware in Swagger

/**
 * @OA\SecurityScheme(
 *     scheme="bearer",
 *     type="http",
 *     description="JWT Token",
 *     @OA\Bearer(
 *         securityScheme="bearer",
 *         bearerFormat="JWT",
 *         type="http",
 *         scheme="bearer",
 *         in="header",
 *         name="Authorization",
 *         example="Bearer {token}"
 *     )
 * )
 * @OA\Get(
 *     path="/api/v1/schools/{id}",
 *     summary="Get school details",
 *     security={{"bearer":{}}},
 *     @OA\Parameter(
 *         name="id",
 *         in="path",
 *         required=true,
 *         @OA\Schema(type="integer")
 *     ),
 *     @OA\Response(response=200, description="Success")
 * )
 */

The Takeaway

Three simple middleware patterns solved our multi-tenant SaaS complexity:

  1. CheckRole — Simple RBAC on endpoints
  2. SchoolAdminScope — Tenant isolation at runtime
  3. AuditMiddleware — Compliance logging automatically

When to use them:

  • Multi-tenant SaaS with role-based access control
  • Compliance-heavy systems (GDPR, healthcare, education)
  • Security-sensitive APIs

Don't over-engineer:

  • Simple role checks → CheckRole
  • Tenant isolation → Scope middleware
  • Logging → Audit middleware

Start simple, add complexity only when needed.


Have you built a multi-tenant system? How do you handle tenant isolation? Drop a comment — I'm curious how others approach this.

More from this blog

M

Masud Rana

33 posts

I am highly skilled full-stack software engineer specializing in Laravel, PHP, JS, React, Vue, Inertia.js, and Shopify, with strong experience in Filament Frontend and prompt engineering.