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)
- Recipe 1: Classic RBAC with Wildcards
- Recipe 2: The "One Exception" User
- Recipe 3: The "Everything Except" Role
- Recipe 4: Your Org Chart, as Code
- Recipe 5: Owner-Only Editing (ABAC)
- Recipe 6: Department-Scoped Approvals
- Recipe 7: The Multi-Tenant Admin
- Recipe 8: The 30-Day Contractor
- Recipe 9: Bulletproof Route Protection
- Recipe 10: Conditional UI Without the Mess
- Recipe 11: The Compliance Trail
- Recipe 12: The 2 AM Fix
- Bonus Recipe: Testing Authorization
- Comparison with Spatie
- Final Thoughts
🔪 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
// app/Models/User.php
use HosseinHezami\PermissionManager\Traits\PermissionTrait;
class User extends Authenticatable
{
use PermissionTrait;
}
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
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)
And when the quarter ends:
$sarah->revokePermissionTo('reports.export');
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
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
Visualize it anytime:
php artisan permission:tree
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',
],
]);
Now the check is resource-aware:
$user->canPermission('posts.update', $myPost); // ✅ owns it
$user->canPermission('posts.update', $someoneElses); // ❌ not the owner
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],
],
],
]);
$manager->canPermission('expenses.approve', $teamExpense); // ✅ same dept, $500
$manager->canPermission('expenses.approve', $otherDeptExpense); // ❌ different dept
$manager->canPermission('expenses.approve', $bigExpense); // ❌ $5,000
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); // ❌
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
});
🍳 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
Housekeeping (schedule weekly):
php artisan permission:prune --days=7
🍳 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']);
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
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();
Enable it in config:
'audit' => ['enabled' => true, 'log_mutations' => true],
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
User: 42
Ability: posts.delete
✗ DENIED
Reason: explicit_user_deny
Source: direct_permission
Details:
- matched_pattern: posts.delete
Or programmatically:
$result = PermissionManager::explain($user, 'posts.delete');
// ['allowed' => false, 'reason' => 'explicit_user_deny', 'source' => ..., 'user' => [...]]
And for a full picture of any user's state:
$snapshot = PermissionManager::snapshot($user);
// roles, allow list, deny list, inherited roles, merged permissions
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);
}
}
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:
- 🎭 Wildcard RBAC for the norm
- 🎯 Direct permissions for exceptions
- 🚫 Explicit deny for "everything except"
- 🌳 Hierarchy for org charts
- 🧠 ABAC for ownership & context
- 🏢 Teams for tenants
- ⏱️ Expiry for contractors
- 🛡️ Middleware DSL for routes
- 🎨 Blade directives for UI
- 📝 Audit for compliance
- 🔍 Explain API for debugging
composer require hosseinhezami/laravel-permission-manager
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)