DEV Community

Cover image for The Laravel Authorization Cookbook: 12 Production-Ready Recipes for RBAC, ABAC, Teams & Deny Rules
Hossein Hezami
Hossein Hezami

Posted on

The Laravel Authorization Cookbook: 12 Production-Ready Recipes for RBAC, ABAC, Teams & Deny Rules

TL;DR: Authorization tutorials show you theory. Production apps need recipes. This cookbook gives you 12 copy-paste-ready patterns — from classic RBAC to owner-only ABAC rules, team-scoped admins, expiring contractor access, and audit trails — all built on Laravel Permission Manager. Every recipe includes the exact code you'd ship.

🔗 GitHub Repository · 📦 Packagist


📋 Table of Contents


🔪 Prep Your Kitchen (Installation)

Every recipe below assumes this setup (takes ~60 seconds):

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
// app/Models/User.php
use HosseinHezami\PermissionManager\Traits\PermissionTrait;

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

Now let's cook. 👨‍🍳


🍳 Recipe 1: Classic RBAC with Wildcards

Scenario: "Editors can do everything with posts."

Instead of listing ten permissions, use a wildcard:

use HosseinHezami\PermissionManager\Models\Role;

$editor = Role::create(['name' => 'Editor', 'slug' => 'editor']);

// One wildcard instead of ten lines
$editor->assignPermission('posts.*');

$user->assignRole('editor');

$user->hasPermissionTo('posts.view');    // ✅ true
$user->hasPermissionTo('posts.publish'); // ✅ true
$user->hasPermissionTo('users.delete');  // ❌ false
Enter fullscreen mode Exit fullscreen mode

Chef's note: Wildcards support posts.*, *.edit, admin.*, and even * — matching happens in a dedicated engine, not scattered regex in your controllers.


🍳 Recipe 2: The "One Exception" User

Scenario: "Marketing needs report export, but only for Sarah this quarter."

Don't create a role for one person. Grant a direct permission:

$sarah->givePermissionTo('reports.export');

$sarah->hasDirectPermission('reports.export'); // ✅ true
$sarah->hasPermissionTo('reports.export');     // ✅ true (direct counts)
Enter fullscreen mode Exit fullscreen mode

And when the quarter ends:

$sarah->revokePermissionTo('reports.export');
Enter fullscreen mode Exit fullscreen mode

Chef's note: Direct permissions are checked alongside role permissions in the resolution chain — no special-casing in your code.


🍳 Recipe 3: The "Everything Except" Role

Scenario: "Interns get all post permissions, except delete."

This is where most RBAC systems collapse into role explosion. Here it's two lines, thanks to explicit deny:

$intern = Role::create(['name' => 'Intern', 'slug' => 'intern']);

$intern->assignPermission('posts.*');      // grant everything...
$intern->denyPermission('posts.delete');   // ...except this

$user->assignRole('intern');

$user->hasPermissionTo('posts.edit');   // ✅ true
$user->hasPermissionTo('posts.delete'); // ❌ false — deny always wins
Enter fullscreen mode Exit fullscreen mode

Chef's note: The resolution order guarantees deny beats allow, at both user and role level. One rule eliminates dozens of "exception roles."


🍳 Recipe 4: Your Org Chart, as Code

Scenario: "Admins inherit everything editors can do; editors inherit everything viewers can do."

Model the hierarchy once — permissions cascade forever:

$viewer->assignPermission('posts.view');
$editor->assignPermission('posts.edit');
$admin->assignPermission('users.manage');

$editor->inheritFrom('viewer');
$admin->inheritFrom('editor');

// An admin now holds ALL of these:
$user->assignRole('admin');
$user->hasPermissionTo('posts.view');  // ✅ from viewer (2 levels down)
$user->hasPermissionTo('posts.edit');  // ✅ from editor (1 level down)
$user->hasPermissionTo('users.manage'); // ✅ direct
Enter fullscreen mode Exit fullscreen mode

Visualize it anytime:

php artisan permission:tree
Enter fullscreen mode Exit fullscreen mode

Chef's note: Cycles (A → B → A) throw a CyclicRoleInheritanceException instead of hanging your app. Safety is built in.


🍳 Recipe 5: Owner-Only Editing (ABAC)

Scenario: "Users can edit posts — but only their own."

Roles can't express "their own." Attribute-Based Access Control can:

use HosseinHezami\PermissionManager\Models\Permission;
use HosseinHezami\PermissionManager\Models\PermissionCondition;

PermissionCondition::create([
    'permission_id' => Permission::findByRoute('posts.update')->id,
    'name' => 'owner-only',
    'conditions' => [
        'field' => 'user.id',
        'operator' => '=',
        'value' => 'resource.owner_id',
    ],
]);
Enter fullscreen mode Exit fullscreen mode

Now the check is resource-aware:

$user->canPermission('posts.update', $myPost);      // ✅ owns it
$user->canPermission('posts.update', $someoneElses); // ❌ not the owner
Enter fullscreen mode Exit fullscreen mode

Chef's note: The condition engine is whitelist-based JSON — no eval(), no code injection. Safe by design.


🍳 Recipe 6: Department-Scoped Approvals

Scenario: "Managers approve expenses — but only for their own department, and only under $1,000."

Combine logical operators (all / any / not) and comparison operators:

PermissionCondition::create([
    'permission_id' => Permission::findByRoute('expenses.approve')->id,
    'name' => 'dept-manager-limit',
    'conditions' => [
        'all' => [
            ['field' => 'user.department_id', 'operator' => '=', 'value' => 'resource.department_id'],
            ['field' => 'resource.amount', 'operator' => '<=', 'value' => 1000],
        ],
    ],
]);
Enter fullscreen mode Exit fullscreen mode
$manager->canPermission('expenses.approve', $teamExpense);    // ✅ same dept, $500
$manager->canPermission('expenses.approve', $otherDeptExpense); // ❌ different dept
$manager->canPermission('expenses.approve', $bigExpense);      // ❌ $5,000
Enter fullscreen mode Exit fullscreen mode

Chef's note: Available operators: =, !=, >, >=, <, <=, in, not_in, contains, starts_with, ends_with, exists, not_exists.


🍳 Recipe 7: The Multi-Tenant Admin

Scenario: "Ana is an admin at Acme, but just a viewer at Globex."

Team-scoped roles make one user's permissions different per tenant:

use HosseinHezami\PermissionManager\Models\Team;

$acme = Team::createTeam(['name' => 'Acme Corp', 'slug' => 'acme']);
$globex = Team::createTeam(['name' => 'Globex Inc', 'slug' => 'globex']);

$ana->joinTeam($acme);
$ana->joinTeam($globex);

$ana->assignRoleForTeam('admin', $acme);
$ana->assignRoleForTeam('viewer', $globex);

$ana->hasRoleForTeam('admin', $acme);   // ✅
$ana->hasRoleForTeam('admin', $globex); // ❌
Enter fullscreen mode Exit fullscreen mode

Scope every check in a request via middleware:

Route::middleware(['auth', 'pm.team:header,X-Team-Id'])->group(function () {
    // All permission checks inside respect the active team
});
Enter fullscreen mode Exit fullscreen mode

🍳 Recipe 8: The 30-Day Contractor

Scenario: "The contractor needs billing access for one month. Make sure it dies on its own."

Temporary permissions auto-expire — no cron logic in your app code:

$contractor->givePermissionTo(
    'billing.view',
    'allow',
    now()->addDays(30)   // expires automatically
);

// Today: ✅ true
// Day 31: ❌ false — without you touching anything
Enter fullscreen mode Exit fullscreen mode

Housekeeping (schedule weekly):

php artisan permission:prune --days=7
Enter fullscreen mode Exit fullscreen mode

🍳 Recipe 9: Bulletproof Route Protection

Scenario: "Different routes need AND / OR / NOT permission logic."

The Middleware DSL expresses boolean logic declaratively:

// OR — any of these
Route::get('/reports', [ReportController::class, 'index'])
    ->middleware('pm:permission:any:reports.view|reports.export');

// AND — all of these
Route::post('/projects', [ProjectController::class, 'store'])
    ->middleware('pm:permission:all:projects.view|projects.create');

// NOT — must NOT have this
Route::get('/public', fn () => '...')
    ->middleware('pm:permission:not:admin.panel');

// Role + permission combined
Route::delete('/projects/{project}', [ProjectController::class, 'destroy'])
    ->middleware(['pm:role:admin', 'pm:permission:projects.delete']);
Enter fullscreen mode Exit fullscreen mode

Unauthorized users get a clean 403 automatically.


🍳 Recipe 10: Conditional UI Without the Mess

Scenario: "Show buttons only for what the user can actually do."

Replace nested @if spaghetti with semantic directives:

@role('admin')
    Settings
@endrole

@canpermission('posts.update', $post)
    
@endcanpermission

@hasanyrole(['admin', 'editor'])
    
…
@endhasanyrole @unlesspermission('posts.delete') Deleting is disabled for your account. @endunlesspermission
Enter fullscreen mode Exit fullscreen mode

Chef's note: 12+ directives ship out of the box, including @hasallpermissions, @cannotpermission, and the legacy @hasRole / @hasPermission.


🍳 Recipe 11: The Compliance Trail

Scenario: "The auditor asks: who granted delete rights, and when?"

Every mutation is recorded automatically (actor, action, subject, IP, user agent, metadata):

use HosseinHezami\PermissionManager\Models\PermissionAudit;

// Everything that happened recently
PermissionAudit::latest()->limit(50)->get();

// Everything one admin did
PermissionAudit::byActor($adminId)->get();

// Only grants of deny rules
PermissionAudit::action('granted')->where('effect', 'deny')->get();
Enter fullscreen mode Exit fullscreen mode

Enable it in config:

'audit' => ['enabled' => true, 'log_mutations' => true],
Enter fullscreen mode Exit fullscreen mode

Chef's note: There's also an optional authorization trail (authorization_logging) that logs denied checks with sampling — perfect for spotting probing attempts without flooding logs.


🍳 Recipe 12: The 2 AM Fix

Scenario: "User 42 can't delete posts. Why?"

Stop guessing. Ask the engine:

php artisan permission:why 42 posts.delete
Enter fullscreen mode Exit fullscreen mode
  User:    42
  Ability: posts.delete
  ✗ DENIED
  Reason:  explicit_user_deny
  Source:  direct_permission
  Details:
    - matched_pattern: posts.delete
Enter fullscreen mode Exit fullscreen mode

Or programmatically:

$result = PermissionManager::explain($user, 'posts.delete');
// ['allowed' => false, 'reason' => 'explicit_user_deny', 'source' => ..., 'user' => [...]]
Enter fullscreen mode Exit fullscreen mode

And for a full picture of any user's state:

$snapshot = PermissionManager::snapshot($user);
// roles, allow list, deny list, inherited roles, merged permissions
Enter fullscreen mode Exit fullscreen mode

Chef's note: permission:doctor also scans the whole system for orphan roles, cycles, duplicates, and stale caches — run it in CI.


🎁 Bonus Recipe: Testing Authorization

Ship confidence with the built-in testing helpers:

use HosseinHezami\PermissionManager\Testing\InteractsWithPermissions;
use HosseinHezami\PermissionManager\Testing\PermissionAssertions;

class PostPolicyTest extends TestCase
{
    use InteractsWithPermissions, PermissionAssertions;

    public function test_intern_cannot_delete_posts(): void
    {
        $intern = $this->actingAsRole(['intern']);

        $this->assertHasPermission($intern, 'posts.edit');
        $this->assertDoesNotHavePermission($intern, 'posts.delete');

        $this->deleteJson('/posts/1')->assertStatus(403);
    }
}
Enter fullscreen mode Exit fullscreen mode

The package itself is backed by 141 tests / 226 assertions on isolated SQLite.


📊 Comparison with Spatie

Feature Laravel Permission Manager Spatie Permission
RBAC + Wildcards ✅ ✅
Direct Permissions ✅ ✅
Teams / Multi-Tenancy ✅ ✅
Explicit Allow/Deny ✅ ❌
Role Hierarchy ✅ + cycle detection ❌
ABAC / Conditions ✅ JSON engine (no eval) ❌
Temporary Permissions ✅ Auto-expiry ❌
Audit Logging ✅ Built-in ❌
Explain API / Doctor / Tree ✅ ❌
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 standard RBAC. Laravel Permission Manager adds the production recipes above — deny rules, hierarchy, ABAC, tenancy depth, auditing, and debugging — in one cohesive engine.


💭 Final Thoughts

Authorization in production isn't one feature — it's a dozen recurring patterns. The packages that win are the ones that turn each pattern into two lines of declarative code instead of a custom policy class.

Keep this cookbook handy:

  1. 🎭 Wildcard RBAC for the norm
  2. 🎯 Direct permissions for exceptions
  3. 🚫 Explicit deny for "everything except"
  4. 🌳 Hierarchy for org charts
  5. 🧠 ABAC for ownership & context
  6. 🏢 Teams for tenants
  7. ⏱️ Expiry for contractors
  8. 🛡️ Middleware DSL for routes
  9. 🎨 Blade directives for UI
  10. 📝 Audit for compliance
  11. 🔍 Explain API for debugging
composer require hosseinhezami/laravel-permission-manager
Enter fullscreen mode Exit fullscreen mode

If a recipe saved you time, a ⭐ on GitHub keeps the kitchen open. 🙏


🔗 GitHub Repository · 📦 Packagist


Tags: #laravel #php #authorization #rbac #abac #multitenancy #security #webdev #tutorial #opensource #saas #backend

Top comments (0)