DEV Community

Cover image for The 5 Levels of Laravel Authorization: From Basic RBAC to ABAC, Role Hierarchy & Multi-Tenancy (2026 Edition)
Hossein Hezami
Hossein Hezami

Posted on

The 5 Levels of Laravel Authorization: From Basic RBAC to ABAC, Role Hierarchy & Multi-Tenancy (2026 Edition)

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"

Every Laravel app begins with the same innocent line:

if ($user->is_admin) { ... }
Enter fullscreen mode Exit fullscreen mode

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)
) { ... }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

With wildcards, it gets surprisingly far:

$editor->assignPermission('posts.*');

$user->hasPermissionTo('posts.view');    // ✅
$user->hasPermissionTo('posts.publish'); // ✅
Enter fullscreen mode Exit fullscreen mode

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-delete or admin-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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

🚩 You've outgrown Level 2 when: you find yourself writing admin > manager > editor chains 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');
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

And you should be able to see the structure:

php artisan permission:tree
Enter fullscreen mode Exit fullscreen mode
super-admin (4 permissions)
└── admin (6 permissions)
    └── editor (3 permissions)
        └── viewer (1 permissions)
Enter fullscreen mode Exit fullscreen mode

🚩 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'],
        ],
    ],
]);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'],
    ]],
]]
Enter fullscreen mode Exit fullscreen mode

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');
});
Enter fullscreen mode Exit fullscreen mode

🚩 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);  // ❌
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  ✗ DENIED
  Reason:  explicit_user_deny
  Source:  direct_permission
  Details:
    - matched_pattern: posts.delete
Enter fullscreen mode Exit fullscreen mode

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 if logic)

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
Enter fullscreen mode Exit fullscreen mode
use HosseinHezami\PermissionManager\Traits\PermissionTrait;

class User extends Authenticatable
{
    use PermissionTrait;
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

📊 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:

  1. 🥉 RBAC for identity
  2. 🥈 Direct + Deny for exceptions
  3. 🥇 Hierarchy for scale
  4. 💎 ABAC for context
  5. 🏆 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
Enter fullscreen mode Exit fullscreen mode

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)