TL;DR: Most Laravel apps hit the same 5 authorization walls as they grow — role explosion, exception handling, multi-tenancy, contextual permissions, and debugging nightmares. This deep dive shows how to solve each one with modern patterns, and introduces a package that combines all solutions: Laravel Permission Manager.
📋 Table of Contents
- Introduction: The Authorization Ceiling
- Problem #1: The Role Explosion Trap
- Problem #2: The “Except This One” Problem
- Problem #3: The Multi-Tenant Nightmare
- Problem #4: The “Can They Edit THIS Post?” Problem
- Problem #5: The Silent Cache Bug
- Bonus: The 3 AM Debugging Nightmare
- The Complete Solution
- Real-World Implementation
- Comparison with Spatie
- Final Thoughts
🎯 Introduction: The Authorization Ceiling
Every Laravel project starts with the same authorization story:
// Day 1: Simple and beautiful
if ($user->is_admin) {
// show admin stuff
}
Enter fullscreen mode Exit fullscreen mode
By month three, it looks like this:
// Month 3: Starting to hurt
if ($user->hasRole('admin') ||
($user->hasRole('editor') && $post->status === 'draft') ||
($user->hasRole('manager') && $post->department_id === $user->department_id)) {
// ...
}
Enter fullscreen mode Exit fullscreen mode
By year one, you’ve got authorization logic scattered across controllers, policies, middleware, and blade templates — with no clear source of truth.
This is what I call “The Authorization Ceiling”: the point where basic RBAC stops working and you need something more sophisticated.
In this article, we’ll explore the 5 most common authorization problems Laravel developers hit, why traditional solutions fail, and how modern patterns (and modern packages) solve them cleanly.
🔴 Problem #1: The Role Explosion Trap
The Symptom
Your application has roles: admin, editor, viewer. Life is good.
Then the product team asks:
- “Can we have an admin who can’t delete users?”
- “Can we have an editor who can publish but not delete?”
- “Can we have a viewer who can export reports?”
Before you know it, you have 47 roles in your database. Every new exception creates a new role. HR calls it “role explosion.” You call it “nightmare fuel.”
Why Traditional RBAC Fails
Pure RBAC assumes a clean mapping: User → Role → Permissions.
Real-world requirements look like this:
User → Role(s) → Permissions → EXCEPTIONS
The “exceptions” part is what breaks pure RBAC. You need a way to grant or deny permissions directly to users, independent of their roles.
The Solution: Direct Permissions + Role Composition
Modern authorization systems support two parallel permission channels:
// Channel 1: Role-based (the norm)
$user->assignRole('editor');
// Gets: posts.view, posts.create, posts.edit
// Channel 2: Direct permissions (the exception)
$user->givePermissionTo('reports.export');
$user->denyPermissionTo('posts.delete');
// Final result:
// - posts.view ✅ (from role)
// - posts.create ✅ (from role)
// - posts.edit ✅ (from role)
// - posts.delete ❌ (explicit deny wins!)
// - reports.export ✅ (direct grant)
Enter fullscreen mode Exit fullscreen mode
The Resolution Order
The magic is in the resolution order. A well-designed system checks in this priority:
1. Super Admin bypass
2. Explicit User DENY ← highest priority
3. Explicit Role DENY
4. Explicit User ALLOW
5. Role ALLOW
6. Inherited permissions
7. Default: DENY
Enter fullscreen mode Exit fullscreen mode
Deny always wins over allow. This single rule eliminates 80% of role explosion.
🔴 Problem #2: The “Except This One” Problem
The Symptom
A manager tells you: “Give the marketing team access to everything in the CMS — except the ability to delete blog posts.”
In basic RBAC, you have three bad options:
-
Create a new role (
marketing-no-delete) — now you have 48 roles -
Don’t grant
posts.delete— but then you can’t use the convenientposts.*wildcard - Write custom middleware — now you have logic scattered everywhere
Why Wildcards Alone Don’t Help
Wildcards are great for granting permissions:
$role->assignPermission('posts.*'); // Easy!
Enter fullscreen mode Exit fullscreen mode
But they’re useless for exceptions. You can’t express “everything except X” with a simple wildcard.
The Solution: Explicit Deny with Wildcards
Modern systems combine wildcards with explicit deny:
// Grant everything in posts.*
$marketing->assignPermission('posts.*');
// But explicitly deny delete
$marketing->denyPermission('posts.delete');
// Or use negation wildcards (advanced)
$marketing->assignPermission('!posts.delete');
Enter fullscreen mode Exit fullscreen mode
The Pattern in Action
// Senior Editor role
$seniorEditor = Role::create(['name' => 'Senior Editor']);
$seniorEditor->assignPermission([
'posts.*', // All post operations
'categories.*', // All category operations
'!posts.delete', // EXCEPT deleting posts
'!posts.force-delete', // AND force-deleting
]);
// Now any user with this role:
$user->assignRole('senior-editor');
$user->hasPermissionTo('posts.view'); // ✅ true
$user->hasPermissionTo('posts.create'); // ✅ true
$user->hasPermissionTo('posts.edit'); // ✅ true
$user->hasPermissionTo('posts.publish'); // ✅ true
$user->hasPermissionTo('posts.delete'); // ❌ false (denied)
$user->hasPermissionTo('posts.force-delete'); // ❌ false (denied)
Enter fullscreen mode Exit fullscreen mode
One role. Multiple exceptions. Zero role explosion.
🔴 Problem #3: The Multi-Tenant Nightmare
The Symptom
You’re building a SaaS. Company A has their own users with their own roles. Company B has the same. But they’re in the same database.
Suddenly you realize:
- An “admin” in Company A should NOT be admin in Company B
- A user might be admin in Company A but only a viewer in Company B
- Permissions need to be scoped to a tenant
Why Global Roles Fail
Traditional RBAC treats roles as global:
$user->assignRole('admin');
// Admin everywhere. In every tenant. Forever.
Enter fullscreen mode Exit fullscreen mode
For SaaS, you need:
$user->assignRoleForTeam('admin', $companyA);
$user->assignRoleForTeam('viewer', $companyB);
Enter fullscreen mode Exit fullscreen mode
The Solution: Team-Scoped Roles
Modern multi-tenant authorization introduces a team context:
// Setup
$companyA = Team::createTeam(['name' => 'Acme Corp']);
$companyB = Team::createTeam(['name' => 'Globex Inc']);
// User belongs to both companies
$user->joinTeam($companyA);
$user->joinTeam($companyB);
// Different roles per company
$user->assignRoleForTeam('admin', $companyA);
$user->assignRoleForTeam('editor', $companyB);
// Check permissions in context
$user->hasRoleForTeam('admin', $companyA); // ✅ true
$user->hasRoleForTeam('admin', $companyB); // ❌ false
$user->hasRoleForTeam('editor', $companyB); // ✅ true
Enter fullscreen mode Exit fullscreen mode
Setting the Active Tenant
In controllers, you need to know which tenant is active:
// Option 1: Middleware (recommended)
Route::middleware(['pm.team:header,X-Team-Id'])->group(function () {
// All permission checks here are scoped to the team from the header
});
// Option 2: Programmatic
PermissionManager::setTeam($currentTeam);
$user->hasPermissionTo('projects.delete'); // Scoped to $currentTeam
Enter fullscreen mode Exit fullscreen mode
Database Design
The schema adds a team_id pivot column:
user_roles
├── user_id
├── role_id
├── team_id ← THE key addition
└── expires_at
Enter fullscreen mode Exit fullscreen mode
This lets one user have different roles in different tenants — without duplicating users.
🔴 Problem #4: The “Can They Edit THIS Post?” Problem
The Symptom
The requirement sounds simple: “Users can edit posts they own.”
But it’s deceptively complex:
- “Own” is contextual — it depends on the specific post
- A user might own Post A but not Post B
- Admins can edit any post
- Editors can edit their department’s posts
Why Pure RBAC Can’t Handle This
RBAC is binary: “Can the user do X? Yes or no.”
But contextual authorization is conditional: “Can the user do X to Y?”
You need ABAC — Attribute-Based Access Control.
The Solution: Condition Engine
Modern systems define permissions with JSON conditions:
// Define the permission with a condition
PermissionCondition::create([
'permission_id' => $permission->id,
'name' => 'owner-only',
'conditions' => [
'field' => 'user.id',
'operator' => '=',
'value' => 'resource.owner_id',
],
]);
Enter fullscreen mode Exit fullscreen mode
Then check it against a specific resource:
$user->canPermission('posts.edit', $post);
// Returns true ONLY if $user->id === $post->owner_id
Enter fullscreen mode Exit fullscreen mode
Complex Conditions
Real-world rules are rarely simple:
// Rule: Users can edit posts they own,
// BUT only if the post is in draft status,
// OR if the user is a department manager in the same department
PermissionCondition::create([
'conditions' => [
'any' => [
// Condition 1: Owner + Draft
['all' => [
['field' => 'user.id', 'operator' => '=', 'value' => 'resource.owner_id'],
['field' => 'resource.status', 'operator' => '=', 'value' => 'draft'],
]],
// Condition 2: Department manager
['all' => [
['field' => 'user.role', 'operator' => '=', 'value' => 'department-manager'],
['field' => 'user.department_id', 'operator' => '=', 'value' => 'resource.department_id'],
]],
],
],
]);
Enter fullscreen mode Exit fullscreen mode
Supported Operators
A good condition engine supports:
Category Operators Equality=, !=
Comparison
>, >=, <, <=
Collections
in, not_in, contains
Strings
starts_with, ends_with
Existence
exists, not_exists
Security Note
⚠️ Critical: The condition engine must be whitelist-based, never using eval() or dynamic code execution. JSON rules are interpreted, not executed.
🔴 Problem #5: The Silent Cache Bug
The Symptom
You change a role’s permissions. Users still see old permissions for hours. Support tickets flood in.
Or worse: you revoke a user’s admin role, but they can still access admin routes because the cache wasn’t invalidated.
Why Naive Caching Fails
Basic cache strategies only invalidate the changed entity:
$role->assignPermission('users.delete');
$role->forgetCachedPermissions(); // Only clears role cache!
Enter fullscreen mode Exit fullscreen mode
But what about the 500 users who have this role? Their user-level cache still says they don’t have users.delete.
The Solution: Hierarchical Cache Invalidation
Modern systems use cascading invalidation:
// When you change a role's permission:
$role->forgetCachedPermissions();
// This should:
// 1. Clear the role's own cache
// 2. Clear cache for ALL users who have this role
// 3. Clear cache for roles that INHERIT from this role
// 4. Clear cache for users of those inherited roles
Enter fullscreen mode Exit fullscreen mode
Cache Tags (When Available)
With Redis or Memcached, use cache tags for surgical invalidation:
Cache::tags(['role:42'])->flush(); // Everything tagged with role 42
Cache::tags(['user:123'])->flush(); // Everything for user 123
Cache::tags(['permissions:global'])->flush(); // Global permission list
Enter fullscreen mode Exit fullscreen mode
Cache Warming
For production deployments, warm the cache proactively:
php artisan permission:cache:warm
# Preloads all role permissions, user permissions, etc.
Enter fullscreen mode Exit fullscreen mode
🌟 Bonus: The 3 AM Debugging Nightmare
The Symptom
It’s 3 AM. A user reports they can’t access a feature they should have access to. You check:
- ✅ User has the
editorrole - ✅
editorrole hasposts.publishpermission - ✅ User is logged in
- ❌ But
hasPermissionTo('posts.publish')returnsfalse
Why?!
The Solution: Explain API
Modern authorization systems include an Explain API that shows exactly why a decision was made:
$ php artisan permission:why 42 posts.publish
┌─────────────────────────────────────────────────┐
│ Permission Decision Explanation │
└─────────────────────────────────────────────────┘
User: 42 (John Doe)
Ability: posts.publish
✗ DENIED
Reason: explicit_user_deny
Source: direct_permission
Details:
- matched_pattern: posts.publish
- permission_id: 15
- effect: deny
- expires_at: null
Resolution chain:
✓ Role 'editor' allows posts.* (would normally grant)
✗ User has explicit DENY on posts.publish
→ Final decision: DENY (explicit deny wins)
Enter fullscreen mode Exit fullscreen mode
Suddenly it’s obvious: someone added a direct deny permission to this specific user. Maybe during testing. Maybe by mistake. Now you know.
Programmatic Access
$result = PermissionManager::explain($user, 'posts.publish');
// Returns:
[
'allowed' => false,
'ability' => 'posts.publish',
'reason' => 'explicit_user_deny',
'source' => 'direct_permission',
'metadata' => [
'matched_pattern' => 'posts.publish',
'permission_id' => 15,
],
'user' => [
'id' => 42,
'roles' => ['editor'],
'direct_permissions' => ['!posts.publish'],
],
]
Enter fullscreen mode Exit fullscreen mode
Health Check Command
Go one step further with a “doctor” command:
$ php artisan permission:doctor
✓ No orphan permissions
✓ No orphan roles
✗ Cyclic role inheritance detected: A → B → A
✓ No duplicate roles
⚠ 15 expired permissions can be pruned
✓ Cache consistency OK
Enter fullscreen mode Exit fullscreen mode
🎁 The Complete Solution
All five problems (and the bonus one) are solved by a single, cohesive package:
📦 Laravel Permission Manager v2.0
An enterprise-grade authorization engine that combines:
- ✅ RBAC + Direct Permissions + Allow/Deny
- ✅ Role Hierarchy with multi-level inheritance
- ✅ Teams / Multi-Tenancy
- ✅ ABAC with JSON-based Condition Engine
- ✅ Audit Logging for all changes
- ✅ Multi-Guard support
- ✅ Temporary Permissions with auto-expiry
- ✅ Explain API and CLI Doctor
- ✅ 141 passing tests with 226 assertions
- ✅ 100% backward compatible with v1
Quick Installation
composer require hosseinhezami/laravel-permission-manager
php artisan vendor:publish --provider="HosseinHezami\PermissionManager\PermissionManagerServiceProvider" --tag="migrations"
php artisan migrate
Enter fullscreen mode Exit fullscreen mode
Add Trait
use HosseinHezami\PermissionManager\Traits\PermissionTrait;
class User extends Authenticatable
{
use PermissionTrait;
}
Enter fullscreen mode Exit fullscreen mode
💡 Real-World Implementation
Let’s see how all these patterns work together in a real SaaS application.
Scenario: Multi-Tenant Project Management Tool
// Setup teams
$acme = Team::createTeam(['name' => 'Acme Corp']);
$globex = Team::createTeam(['name' => 'Globex Inc']);
// Setup role hierarchy
$viewer = Role::create(['name' => 'Viewer']);
$editor = Role::create(['name' => 'Editor']);
$admin = Role::create(['name' => 'Admin']);
$editor->inheritFrom('viewer');
$admin->inheritFrom('editor');
// Assign permissions
$viewer->assignPermission('projects.view');
$editor->assignPermission(['projects.edit', 'tasks.create']);
$admin->assignPermission(['projects.delete', 'members.manage']);
// A contractor joins both companies
$contractor = User::create([...]);
$contractor->joinTeam($acme);
$contractor->joinTeam($globex);
// Different roles per company
$contractor->assignRoleForTeam('admin', $acme);
$contractor->assignRoleForTeam('editor', $globex);
// Temporary 30-day access
$contractor->givePermissionTo(
'billing.view',
'allow',
now()->addDays(30)
);
// Conditional permission: editors can only edit their own tasks
PermissionCondition::create([
'permission_id' => Permission::findByRoute('tasks.edit')->id,
'conditions' => [
'all' => [
['field' => 'user.id', 'operator' => '=', 'value' => 'resource.assignee_id'],
['field' => 'resource.status', 'operator' => '!=', 'value' => 'completed'],
],
],
]);
Enter fullscreen mode Exit fullscreen mode
Controller Example
class TaskController extends Controller
{
public function update(Request $request, Team $team, Task $task)
{
// Set team context
PermissionManager::setTeam($team);
// ABAC check with resource
if (!auth()->user()->canPermission('tasks.edit', $task)) {
abort(403, 'You cannot edit this task');
}
$task->update($request->validated());
// Audit log is automatic!
return response()->json(['status' => 'updated']);
}
}
Enter fullscreen mode Exit fullscreen mode
Middleware Example
Route::middleware(['auth', 'pm.team:route,team', 'pm:role:admin|editor'])->group(function () {
Route::get('/projects', [ProjectController::class, 'index']);
Route::post('/projects', [ProjectController::class, 'store'])
->middleware('pm:permission:projects.create');
});
Enter fullscreen mode Exit fullscreen mode
Blade Example
@role('admin')
<a href="{{ route('admin.settings') }}">Admin Settings</a>
@endrole
@canpermission('tasks.edit', $task)
<button @click="editTask({{ $task->id }})">Edit</button>
@endcanpermission
@hasanyrole(['admin', 'editor'])
<div class="editor-toolbar">...</div>
@endhasanyrole
Enter fullscreen mode Exit fullscreen mode
📊 Comparison with Spatie
Spatie’s laravel-permission is the gold standard with 110M+ downloads. Here’s how Laravel Permission Manager v2.0 compares:
When to Choose Spatie
- ✅ Massive community support
- ✅ Extensive documentation
- ✅ Proven in production for years
- ✅ Lots of third-party integrations
When to Choose Laravel Permission Manager
- ✅ You need role hierarchy (Spatie doesn’t have it)
- ✅ You need explicit deny (critical for exceptions)
- ✅ You need ABAC / conditional permissions
- ✅ You need built-in audit logging
- ✅ You need to debug why access was denied
- ✅ You want modern CLI tools (doctor, why, explain, tree)
- ✅ You need temporary permissions with auto-expiry
- ✅ You want 100% backward compatibility with v1
💭 Final Thoughts
Authorization is one of those problems that looks simple on day 1 and becomes a monster by month 12.
The five problems we explored — role explosion, exception handling, multi-tenancy, contextual permissions, and cache bugs — aren’t edge cases. They’re the inevitable evolution of every serious Laravel application.
The good news? You don’t have to solve them one by one with custom code scattered across your codebase. Modern authorization packages like Laravel Permission Manager give you all the patterns in a single, cohesive system.
Key Takeaways
- Pure RBAC isn’t enough — you need direct permissions and explicit deny
-
Roles explode without deny rules —
!posts.deletebeats creating 50 roles - Multi-tenancy requires team context — global roles don’t work for SaaS
-
ABAC handles contextual rules — JSON conditions, no
eval(), no mess - Cache invalidation must be hierarchical — or you’ll ship stale permissions
- Debug tools are non-negotiable — the Explain API saves 3 AM debugging sessions
Next Steps
If any of these problems sound familiar, give Laravel Permission Manager a try:
composer require hosseinhezami/laravel-permission-manager
Enter fullscreen mode Exit fullscreen mode
It supports Laravel 10, 11, 12, and 13, works with PHP 8.2+, and is 100% backward compatible — so it won’t break your existing code.
🔗 GitHub Repository · 📦 Packagist
Tags: #laravel #php #authorization #rbac #abac #security #webdev #tutorial #opensource #saas
Found this helpful? Star ⭐ the GitHub repo and share it with your team!