Multi-Level Authorization in Laravel: Policies, Roles, and ABAC with Redis Caching
Introduction: Limitations of Standard Laravel Gate and Policies
Laravel provides powerful built-in authorization tools: Gate, Policy classes, and the auth middleware. For small applications, this is plenty — you can define a few permissions in AuthServiceProvider and move on. However, in enterprise systems with dozens of roles, hundreds of permissions, and complex business rules, the standard approach starts to break down.
Imagine a document management system where access to a file depends not only on the user's role, but also on the department that owns the document, the document's status, a time window (business hours), the geolocation of the request, and the confidentiality level. A standard Gate with hardcoded checks turns into spaghetti code that is impossible to test or maintain.
Another problem is performance. With every HTTP request, Laravel executes database queries to check permissions. In high-load systems, this becomes a bottleneck: even 3–5 extra SQL queries per API call at 1,000 RPS puts enormous pressure on PostgreSQL.
In this article, we will build a multi-level authorization system for Laravel applications that combines a role-based model (RBAC) with attribute-based access control (ABAC), caches computed permissions in Redis, and remains testable at every level. The target audience is Middle/Senior Laravel developers who need a well-thought-out architecture, not just another out-of-the-box library.
Model Comparison: RBAC vs ABAC vs Hybrid Approach
RBAC (Role-Based Access Control) is the classic approach: a user is assigned a role, and a role maps to a set of permissions. Simple to implement and easy for business stakeholders to understand. The limitation: roles do not account for context. You cannot express a rule like "a manager can only edit orders in their own region."
ABAC (Attribute-Based Access Control) — decisions are made based on attributes of the subject (user), object (resource), action, and environment (request context). Maximally flexible, but complex to implement and debug. Without caching, it is expensive in terms of performance.
The hybrid approach — the best of both worlds: RBAC is used as a fast first filter (does the role have the permission at all?), while ABAC serves as a second layer (does the permission apply in this specific context?). This is exactly the approach we will implement.
- Use pure RBAC: small SaaS apps, CMS, admin panels with a clear role hierarchy.
- Use hybrid RBAC+ABAC: financial systems, ERP, document management systems, multi-tenant platforms.
- Use pure ABAC: government systems with regulatory requirements (GDPR, HIPAA) where rules change dynamically.
Designing the Database Schema in PostgreSQL
A well-designed schema is the foundation of the entire system. We use PostgreSQL with JSONB support for storing attributes.
-- System roles
CREATE TABLE roles (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(64) NOT NULL UNIQUE,
display_name VARCHAR(128),
description TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Permissions
CREATE TABLE permissions (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(128) NOT NULL UNIQUE, -- e.g.: documents.edit
resource VARCHAR(64) NOT NULL, -- documents
action VARCHAR(64) NOT NULL, -- edit
description TEXT
);
-- Role-permission mapping
CREATE TABLE role_permissions (
role_id BIGINT REFERENCES roles(id) ON DELETE CASCADE,
permission_id BIGINT REFERENCES permissions(id) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_id)
);
-- Assigning roles to users (with scope support)
CREATE TABLE user_roles (
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
role_id BIGINT REFERENCES roles(id) ON DELETE CASCADE,
scope_type VARCHAR(64), -- e.g.: department, organization
scope_id BIGINT, -- ID of a specific department
expires_at TIMESTAMPTZ,
PRIMARY KEY (user_id, role_id, COALESCE(scope_type, ''), COALESCE(scope_id, 0))
);
-- Resource attributes for ABAC
CREATE TABLE resource_attributes (
id BIGSERIAL PRIMARY KEY,
resource_type VARCHAR(64) NOT NULL,
resource_id BIGINT NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(resource_type, resource_id)
);
CREATE INDEX idx_resource_attrs ON resource_attributes USING GIN(attributes);
CREATE INDEX idx_user_roles_user ON user_roles(user_id);
CREATE INDEX idx_user_roles_scope ON user_roles(scope_type, scope_id);
The scope_type/scope_id column in user_roles allows assigning a role within the context of a specific object — for example, "manager only in department #5." JSONB in resource_attributes provides flexibility without migrations when adding new attributes.
Implementing RBAC in Laravel: Gate, Policy, and Roles
Let's start with a service for loading user permissions:
<?php
namespace App\Authorization;
use App\Models\User;
use Illuminate\Support\Collection;
class PermissionLoader
{
public function loadForUser(User $user): Collection
{
return $user->roles()
->with('permissions')
->get()
->flatMap(fn($role) => $role->permissions)
->pluck('name')
->unique();
}
}
We register permissions in AuthServiceProvider via a custom Gate:
<?php
namespace App\Providers;
use App\Authorization\PermissionLoader;
use App\Authorization\PermissionCache;
use Illuminate\Support\Facades\Gate;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
class AuthServiceProvider extends ServiceProvider
{
public function boot(PermissionLoader $loader, PermissionCache $cache): void
{
// Dynamically define all permissions via Gate::before
Gate::before(function ($user, $ability) use ($loader, $cache) {
// Super-admin bypasses all checks
if ($user->hasRole('super-admin')) {
return true;
}
// Load permissions from cache or DB
$permissions = $cache->get($user->id)
?? tap($loader->loadForUser($user), fn($p) => $cache->put($user->id, $p));
// First level: RBAC check
if (!$permissions->contains($ability)) {
return false;
}
// null = continue to Policy for ABAC check
return null;
});
}
}
Now let's create a Policy for documents with ABAC logic:
<?php
namespace App\Policies;
use App\Models\{User, Document};
use App\Authorization\AbacEvaluator;
class DocumentPolicy
{
public function __construct(private AbacEvaluator $abac) {}
public function view(User $user, Document $document): bool
{
// ABAC: check attributes
return $this->abac->evaluate($user, $document, 'view');
}
public function edit(User $user, Document $document): bool
{
return $this->abac->evaluate($user, $document, 'edit');
}
public function delete(User $user, Document $document): bool
{
// Additional check: finalized documents cannot be deleted
if ($document->status === 'finalized') {
return false;
}
return $this->abac->evaluate($user, $document, 'delete');
}
}
Extending to ABAC: Attribute Evaluation in AbacEvaluator
The key class is AbacEvaluator, which evaluates rules based on the attributes of the subject, object, and context:
<?php
namespace App\Authorization;
use App\Models\{User, Document};
use Illuminate\Http\Request;
class AbacEvaluator
{
public function __construct(private Request $request) {}
public function evaluate(User $user, object $resource, string $action): bool
{
$rules = $this->getRulesFor(get_class($resource), $action);
foreach ($rules as $rule) {
if (!$this->applyRule($rule, $user, $resource)) {
return false;
}
}
return true;
}
private function applyRule(array $rule, User $user, object $resource): bool
{
return match ($rule['type']) {
// User must be in the same department as the resource
'same_department' =>
$user->department_id === $resource->department_id,
// Time window check (business hours)
'business_hours' =>
now()->isWeekday() && now()->hour >= 9 && now()->hour < 18,
// Confidentiality level
'clearance_level' =>
$user->clearance_level >= ($resource->attributes['sensitivity_level'] ?? 0),
// IP from a trusted subnet
'trusted_network' =>
$this->isFromTrustedNetwork($this->request->ip()),
default => true,
};
}
private function getRulesFor(string $resourceClass, string $action): array
{
// In a real application — load from DB or config
return config("abac.rules.{$resourceClass}.{$action}", []);
}
private function isFromTrustedNetwork(string $ip): bool
{
$trustedRanges = config('abac.trusted_networks', []);
foreach ($trustedRanges as $range) {
if ($this->ipInRange($ip, $range)) return true;
}
return false;
}
private function ipInRange(string $ip, string $cidr): bool
{
[$subnet, $bits] = explode('/', $cidr);
return (ip2long($ip) >> (32 - (int)$bits)) === (ip2long($subnet) >> (32 - (int)$bits));
}
}
Caching Permissions in Redis: Strategy and Invalidation
Redis is the ideal tool for caching computed permissions. We use Laravel cache tags for grouped invalidation:
<?php
namespace App\Authorization;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Collection;
class PermissionCache
{
private const TTL = 3600; // 1 hour
private const TAG_PREFIX = 'user_permissions';
public function get(int $userId): ?Collection
{
return Cache::tags([$this->tag($userId)])
->get($this->key($userId));
}
public function put(int $userId, Collection $permissions): void
{
Cache::tags([$this->tag($userId), 'permissions'])
->put($this->key($userId), $permissions, self::TTL);
}
// Invalidate when a specific user's role or permissions change
public function invalidateUser(int $userId): void
{
Cache::tags([$this->tag($userId)])->flush();
}
// Invalidate all cached permissions (when the role schema changes)
public function invalidateAll(): void
{
Cache::tags(['permissions'])->flush();
}
private function key(int $userId): string
{
return "permissions:user:{$userId}";
}
private function tag(int $userId): string
{
return self::TAG_PREFIX . ":{$userId}";
}
}
Invalidation is wired up via Eloquent events. In the UserRole model:
<?php
namespace App\Models;
use App\Authorization\PermissionCache;
use Illuminate\Database\Eloquent\Model;
class UserRole extends Model
{
protected static function booted(): void
{
$invalidate = function (self $model) {
app(PermissionCache::class)->invalidateUser($model->user_id);
};
static::created($invalidate);
static::deleted($invalidate);
static::updated($invalidate);
}
}
The Redis configuration in config/database.php should use PhpRedis with tag support via the redis driver in config/cache.php with 'driver' => 'redis'. Redis is required for tags — the file cache driver does not support them.
Middleware for REST API Authorization
Let's create an expressive middleware that applies policies at the route level:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class AuthorizeResource
{
public function handle(Request $request, Closure $next, string $ability, string $modelParam = null): mixed
{
$user = $request->user();
if ($modelParam) {
$model = $request->route($modelParam);
Gate::authorize($ability, $model);
} else {
Gate::authorize($ability);
}
return $next($request);
}
}
Registering routes with middleware:
// routes/api.php
Route::middleware(['auth:sanctum'])->group(function () {
Route::get('/documents/{document}', [DocumentController::class, 'show'])
->middleware('authorize:documents.view,document');
Route::put('/documents/{document}', [DocumentController::class, 'update'])
->middleware('authorize:documents.edit,document');
Route::delete('/documents/{document}', [DocumentController::class, 'destroy'])
->middleware('authorize:documents.delete,document');
});
Testing the Authorization System
Comprehensive test coverage is a mandatory requirement for any production-grade authorization system.
<?php
namespace Tests\Unit\Policies;
use App\Models\{User, Document};
use App\Policies\DocumentPolicy;
use App\Authorization\AbacEvaluator;
use Tests\TestCase;
use Mockery;
class DocumentPolicyTest extends TestCase
{
public function test_user_cannot_edit_finalized_document(): void
{
$user = User::factory()->create(['clearance_level' => 3]);
$document = Document::factory()->make([
'status' => 'finalized',
'department_id' => $user->department_id,
]);
$policy = new DocumentPolicy(app(AbacEvaluator::class));
$this->assertFalse($policy->delete($user, $document));
}
public function test_user_can_view_document_in_same_department(): void
{
$user = User::factory()->create(['department_id' => 5, 'clearance_level' => 1]);
$document = Document::factory()->make([
'department_id' => 5,
'attributes' => ['sensitivity_level' => 1],
]);
$this->assertTrue(Gate::forUser($user)->allows('documents.view', $document));
}
public function test_permission_cache_is_invalidated_on_role_change(): void
{
$user = User::factory()->create();
$cache = app(PermissionCache::class);
// Populate the cache
$cache->put($user->id, collect(['documents.view']));
$this->assertNotNull($cache->get($user->id));
// Change the role — invalidation should be triggered
$user->roles()->sync([]);
$this->assertNull($cache->get($user->id));
}
}
Performance: Benchmarks With and Without Cache
We ran load tests on the REST API endpoint GET /documents/{id} using a configuration with PostgreSQL 16, Redis 7.2, Laravel 11, and PHP 8.3 (FrankenPHP).
- Without cache: average authorization time — 18 ms, 95th percentile — 34 ms, 5 SQL queries per permission check.
- With Redis cache (TTL 1 hour): average authorization time — 1.2 ms, 95th percentile — 2.8 ms, 0 SQL queries on cache hit.
- Overall API latency at 500 RPS: without cache — 87 ms, with cache — 24 ms.
The latency savings amounted to approximately 72%. Cache invalidation happens instantly via Eloquent events, ensuring permissions remain up to date.
An important note: when using Redis tags, make sure Redis is configured with sufficient memory and the allkeys-lru eviction policy. Tags store metadata, which increases memory consumption by approximately 15–20% compared to plain caching.
Audit Logging of Authorization Decisions
For compliance requirements (SOC 2, GDPR), authorization decisions must be logged. We add a listener:
<?php
namespace App\Listeners;
use Illuminate\Auth\Access\Events\GateEvaluated;
use Illuminate\Support\Facades\Log;
class LogAuthorizationDecision
{
public function handle(GateEvaluated $event): void
{
if ($event->result === false) {
Log::channel('audit')->warning('Authorization denied', [
'user_id' => $event->user?->id,
'ability' => $event->ability,
'arguments' => collect($event->arguments)->map(fn($a) => [
'type' => get_class($a),
'id' => $a->getKey() ?? null,
])->toArray(),
'ip' => request()->ip(),
'at' => now()->toIso8601String(),
]);
}
}
}
Register the event in EventServiceProvider:
use Illuminate\Auth\Access\Events\GateEvaluated;
use App\Listeners\LogAuthorizationDecision;
protected $listen = [
GateEvaluated::class => [
LogAuthorizationDecision::class,
],
];
Write logs to a dedicated audit channel (configure it in config/logging.php) and, if needed, ship them to a centralized system — ELK Stack or ClickHouse — for analytics on unauthorized access attempts.
Conclusion and Recommendations
Building a multi-level authorization system in Laravel is an architectural decision best made at the start of a project, not refactored during the scaling phase. Here are the key takeaways:
- Hybrid RBAC+ABAC provides the optimal balance between performance and flexibility. RBAC acts as a fast first filter; ABAC serves as a detailed second layer.
- PostgreSQL with JSONB allows storing arbitrary resource attributes without migrations when business rules change.
- Redis with tagged cache reduces authorization latency from 18 ms to 1.2 ms on cache hit. Invalidation via Eloquent events guarantees consistency.
- Do not overuse Gate::before — return
null(notfalse) when you want to pass control to a Policy. Otherwise, you will lose the ABAC check layer. - Test each layer separately: unit tests for AbacEvaluator, Policy, and PermissionCache; integration tests for middleware and routes.
- Audit logging is not optional — it is a requirement for enterprise systems. Set up a dedicated log channel with rotation.
Laravel in 2026 remains an excellent foundation for building complex authorization systems. The standard components (Gate, Policy) are flexible enough to layer any access control model on top of them — the key is to respect the principles of separation of concerns and to prioritize performance from day one.
Technologies
Tags
Ruslan Ismailov
Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →