TL;DR: Authorization maturity isn't binary — it's a ladder. Most Laravel apps start at Level 1 (basic RBAC) and only discover they need Levels 2–5 when the codebase is already on fire. This guide walks through all five levels with real code, shows you how to self-assess your app, and introduces a single package — Laravel Permission Manager — that covers every level.
🔗 GitHub Repository · 📦 Packagist
📋 Table of Contents
- Why Authorization Has "Levels"
- Level 1: Basic RBAC
- Level 2: Direct Permissions & Deny Rules
- Level 3: Role Hierarchy
- Level 4: Contextual Authorization (ABAC)
- Level 5: Multi-Tenancy, Audit & Observability
- Self-Assessment: What Level Is Your App?
- One Package, All Five Levels
- Quick Start
- Comparison with Spatie
- Final Thoughts
🤔 Why Authorization Has "Levels"
Every Laravel app begins with the same innocent line:
if ($user->is_admin) { ... }
And every serious Laravel app eventually ends up somewhere like this:
if (
$user->hasRole('admin') ||
($user->hasRole('editor') && $post->status === 'draft' && $post->user_id === $user->id) ||
($user->hasRole('manager') && $post->department_id === $user->department_id)
) { ... }
That journey from line one to line five is what we call authorization maturity. It happens in predictable stages — and knowing the stages lets you design for them before they hurt.
Here are the five levels.
🥉 Level 1: Basic RBAC
The question it answers: "What kind of user is this?"
Role-Based Access Control is where everyone starts. Users get roles, roles get permissions.
// Assign
$user->assignRole('editor');
// Check
$user->hasRole('editor'); // true
$user->hasPermissionTo('posts.edit'); // true
With wildcards, it gets surprisingly far:
$editor->assignPermission('posts.*');
$user->hasPermissionTo('posts.view'); // ✅
$user->hasPermissionTo('posts.publish'); // ✅
Where Level 1 breaks
The moment someone says "this one editor shouldn't be able to delete posts", pure RBAC has no answer except creating a new role. Do that ten times and you have role explosion.
🚩 You've outgrown Level 1 when: you start naming roles like
editor-no-deleteoradmin-except-billing.
🥈 Level 2: Direct Permissions & Deny Rules
The question it answers: "What about the exceptions?"
Level 2 adds two escape hatches that eliminate 80% of role explosion:
Direct Permissions (grant without a role)
// One user needs report export — no new role needed
$user->givePermissionTo('reports.export');
$user->hasDirectPermission('reports.export'); // ✅ true
Explicit Deny (the "except this one" rule)
// The editor role grants posts.* ...
// but this user must never delete:
$user->denyPermissionTo('posts.delete');
$user->hasPermissionTo('posts.edit'); // ✅ true (from role)
$user->hasPermissionTo('posts.delete'); // ❌ false (deny wins)
The governing rule is simple and powerful:
⚖️ Deny always beats allow.
The resolution order
A well-designed Level 2 system evaluates in a strict priority:
1. Super Admin bypass
2. Explicit User DENY ← highest
3. Explicit Role DENY
4. Explicit User ALLOW
5. Role ALLOW
6. Inherited permissions
7. Default: DENY ← lowest
🚩 You've outgrown Level 2 when: you find yourself writing
admin > manager > editorchains by hand, or duplicating permissions across a dozen similar roles.
🥇 Level 3: Role Hierarchy
The question it answers: "Why am I copying the same permissions into ten roles?"
Level 3 introduces inheritance: roles extend other roles, and permissions cascade automatically.
// Build the chain: super-admin → admin → editor → viewer
$editor->inheritFrom('viewer');
$admin->inheritFrom('editor');
$superAdmin->inheritFrom('admin');
// Assign permissions once, at the right level
$viewer->assignPermission('posts.view');
$editor->assignPermission('posts.edit');
$admin->assignPermission('posts.delete');
Now a super-admin user automatically holds every permission in the chain:
$user->assignRole('super-admin');
$user->hasPermissionTo('posts.view'); // ✅ from viewer (3 up)
$user->hasPermissionTo('posts.edit'); // ✅ from editor (2 up)
$user->hasPermissionTo('posts.delete'); // ✅ from admin (1 up)
Cycle detection matters
A hierarchy without safety checks is a footgun. A → B → A must throw, not hang:
$roleA->inheritFrom('roleB');
$roleB->inheritFrom('roleA');
// ❌ CyclicRoleInheritanceException
And you should be able to see the structure:
php artisan permission:tree
super-admin (4 permissions)
└── admin (6 permissions)
└── editor (3 permissions)
└── viewer (1 permissions)
🚩 You've outgrown Level 3 when: requirements say "users can edit their own posts", "managers approve only their department", or "only while the post is a draft". Roles can't express context.
💎 Level 4: Contextual Authorization (ABAC)
The question it answers: "Can this user do this to **that* specific thing?"*
Attribute-Based Access Control evaluates attributes of the user, the resource, and the environment — not just role membership.
The condition engine
Define rules as safe, declarative JSON (no eval(), whitelist-based operators):
use HosseinHezami\PermissionManager\Models\Permission;
use HosseinHezami\PermissionManager\Models\PermissionCondition;
PermissionCondition::create([
'permission_id' => Permission::findByRoute('posts.update')->id,
'name' => 'owner-and-draft',
'conditions' => [
'all' => [
['field' => 'user.id', 'operator' => '=', 'value' => 'resource.owner_id'],
['field' => 'resource.status', 'operator' => '=', 'value' => 'draft'],
],
],
]);
Then check against a specific resource:
$user->canPermission('posts.update', $myDraftPost); // ✅ owns it + draft
$user->canPermission('posts.update', $myPublishedPost);// ❌ not a draft
$user->canPermission('posts.update', $someoneElsesPost);// ❌ not the owner
The operator toolbox
| Category | Operators |
|---|---|
| Equality |
=, !=
|
| Comparison |
>, >=, <, <=
|
| Collections |
in, not_in, contains
|
| Strings |
starts_with, ends_with
|
| Existence |
exists, not_exists
|
Plus logical composition — all (AND), any (OR), not (negate) — nestable to any depth:
['all' => [
['field' => 'resource.status', 'operator' => '!=', 'value' => 'archived'],
['any' => [
['field' => 'user.id', 'operator' => '=', 'value' => 'resource.owner_id'],
['field' => 'user.role', 'operator' => '=', 'value' => 'admin'],
]],
]]
Custom abilities for full control
For logic that outgrows JSON, register closures:
PermissionManager::define('posts.update', function ($user, $post) {
return $post->user_id === $user->id || $user->hasPermissionTo('posts.edit.any');
});
🚩 You've outgrown Level 4 when: the business says "the same person is an admin at Company A but just a viewer at Company B" — or "prove to the auditor who changed what, when."
🏆 Level 5: Multi-Tenancy, Audit & Observability
The question it answers: "Whose system are we even in — and who changed it?"
The top level is really three capabilities that enterprise apps can't live without.
5a. Team-scoped roles (Multi-Tenancy)
$acme = Team::createTeam(['name' => 'Acme Corp']);
$globex = Team::createTeam(['name' => 'Globex Inc']);
$user->joinTeam($acme);
$user->joinTeam($globex);
// Different role per tenant
$user->assignRoleForTeam('admin', $acme);
$user->assignRoleForTeam('viewer', $globex);
$user->hasRoleForTeam('admin', $acme); // ✅
$user->hasRoleForTeam('admin', $globex); // ❌
Scope every check to the active tenant via middleware or code:
Route::middleware(['pm.team:header,X-Team-Id'])->group(function () {
// All checks inside are tenant-scoped
});
// or
PermissionManager::setTeam($currentTeam);
5b. Audit logging (compliance)
Every mutation is recorded automatically — actor, action, subject, IP, user agent, metadata:
PermissionAudit::byActor($adminId)->latest()->get();
PermissionAudit::action('granted')->get();
5c. Observability (debugging)
When a ticket says "user can't access X", you answer in seconds, not hours:
php artisan permission:why 42 posts.delete
✗ DENIED
Reason: explicit_user_deny
Source: direct_permission
Details:
- matched_pattern: posts.delete
Plus permission:doctor for system-wide health checks and permission snapshots for a complete picture of any user's state.
🏁 Level 5 is the ceiling — and very few packages reach it.
Self-Assessment: What Level Is Your App?
Check every statement that's true about your codebase:
Level 1
- [ ] You use roles + permissions
- [ ] You use wildcards like
posts.*
Level 2
- [ ] You can grant a permission to one user without a role
- [ ] You can explicitly DENY something a role grants
Level 3
- [ ] Roles inherit from other roles
- [ ] Inheritance cycles are detected and rejected
Level 4
- [ ] Permissions can depend on the resource (
own posts only) - [ ] Conditions are declarative (no scattered
iflogic)
Level 5
- [ ] A user can hold different roles in different tenants
- [ ] Permission changes are audited automatically
- [ ] You can explain why any decision was made
Scoring:
- 1 level: Normal for a young app — fine, but plan ahead.
- 2–3 levels: A growing product. You're feeling the pain points.
- 4–5 levels: Enterprise territory. Your authorization is a system, not an accident.
📦 One Package, All Five Levels
Most packages cover Level 1 (some Level 2). Laravel Permission Manager v2.0 implements all five in a single, cohesive engine:
| Level | Feature | API |
|---|---|---|
| 1 | RBAC + wildcards |
assignRole(), hasPermissionTo()
|
| 2 | Direct + deny |
givePermissionTo(), denyPermissionTo()
|
| 3 | Hierarchy |
inheritFrom(), permission:tree
|
| 4 | ABAC |
canPermission($ability, $resource), PermissionCondition
|
| 5 | Tenancy + audit + observability |
assignRoleForTeam(), PermissionAudit, permission:why
|
It also ships the developer-experience layer that makes all of this maintainable:
- 🛡️ Advanced Middleware DSL (
pm:permission:any:...,pm:role:...) - 🎨 12+ Blade directives (
@role,@canpermission,@unlesspermission) - 🔐 Native Laravel Gate integration (
$user->can(),@can) - ⚡ Smart hierarchical cache invalidation
- ⏱️ Temporary permissions with auto-expiry
- 🧪 141 passing tests, 226 assertions
- 🔄 100% backward compatible with v1
🚀 Quick Start
composer require hosseinhezami/laravel-permission-manager
php artisan vendor:publish --provider="HosseinHezami\PermissionManager\PermissionManagerServiceProvider" --tag="migrations"
php artisan migrate
use HosseinHezami\PermissionManager\Traits\PermissionTrait;
class User extends Authenticatable
{
use PermissionTrait;
}
Then climb the ladder at your own pace — every level is opt-in:
// Level 1
$user->assignRole('editor');
// Level 2
$user->denyPermissionTo('posts.delete');
// Level 3
$admin->inheritFrom('editor');
// Level 4
$user->canPermission('posts.update', $post);
// Level 5
$user->assignRoleForTeam('admin', $acme);
📊 Comparison with Spatie
| Feature | Laravel Permission Manager | Spatie Permission |
|---|---|---|
| Level 1: RBAC + Wildcards | ✅ | ✅ |
| Level 2: Direct Permissions | ✅ | ✅ |
| Level 2: Explicit Deny | ✅ | ❌ |
| Level 3: Role Hierarchy | ✅ + cycle detection | ❌ |
| Level 4: ABAC / Conditions | ✅ JSON engine (no eval) | ❌ |
| Level 5: Teams / Multi-Tenancy | ✅ | ✅ |
| Level 5: Audit Logging | ✅ Built-in | ❌ |
| Level 5: Explain API / Doctor | ✅ | ❌ |
| Temporary Permissions | ✅ Auto-expiry | ❌ |
| Permission Groups & Sets | ✅ | ❌ |
| Gate Integration | ✅ Native | ✅ |
| Middleware DSL | ✅ any/all/not | ✅ |
| Blade Directives | ✅ 12+ | ✅ |
| Testing Helpers | ✅ Built-in | ❌ |
| Tests | 141 | ~300 |
| Laravel 13 Support | ✅ | ⚠️ |
| Backward Compatible | ✅ 100% | ✅ |
Bottom line: Spatie is a mature, excellent choice for Levels 1–2 and basic tenancy. Laravel Permission Manager extends the ladder to Levels 3–5 — hierarchy, deny rules, ABAC, auditing, and observability — without sacrificing compatibility.
💭 Final Thoughts
Authorization isn't a feature you add once; it's a capability that matures with your product. The apps that suffer are the ones that discover Level 4 requirements while stuck in a Level 1 architecture.
The fix is to know the ladder — and pick tooling that lets you climb it without rewrites:
- 🥉 RBAC for identity
- 🥈 Direct + Deny for exceptions
- 🥇 Hierarchy for scale
- 💎 ABAC for context
- 🏆 Tenancy + Audit + Observability for enterprise
Wherever your app sits today, design for the level above it. Future-you will thank you.
composer require hosseinhezami/laravel-permission-manager
If this guide helped you map your authorization maturity, a ⭐ on GitHub means a lot!
🔗 GitHub Repository · 📦 Packagist ·
Tags: #laravel #php #authorization #rbac #abac #multitenancy #saas #security #webdev #opensource #tutorial #backend
Top comments (0)