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.

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:
- Role-based access control — which endpoints can which roles access?
- Tenant scoping — school admins can ONLY see their school's data
- 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:

// 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:
- CheckRole — Simple RBAC on endpoints
- SchoolAdminScope — Tenant isolation at runtime
- 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.