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')
    <a href="{{ route('admin.settings') }}">Settings</a>
@endrole

@canpermission('posts.update', $post)
    <button wire:click="edit({{ $post->id }})">Edit</button>
@endcanpermission

@hasanyrole(['admin', 'editor'])
    <div class="editor-toolbar">โ€ฆ</div>
@endhasanyrole

@unlesspermission('posts.delete')
    <span class="muted">Deleting is disabled for your account.</span>
@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)