TL;DR: In this hands-on tutorial, we'll build a complete authorization system for a fictional multi-tenant SaaS app โ "TaskFlow" โ using Laravel Permission Manager. By the end, you'll have role hierarchy, team-scoped roles, conditional (ABAC) permissions, temporary access, audit logging, and debugging tools all working together. Full code included.
๐ GitHub Repository ยท ๐ฆ Packagist
๐ Table of Contents
- What We're Building
- Prerequisites
- Step 1: Installation
- Step 2: Designing the Role Hierarchy
- Step 3: Creating Teams (Tenants)
- Step 4: Team-Scoped Role Assignment
- Step 5: Direct Permissions & Explicit Deny
- Step 6: ABAC โ Conditional Permissions
- Step 7: Temporary Access for Contractors
- Step 8: Protecting Routes with Middleware
- Step 9: Blade Directives for the UI
- Step 10: The Complete Controller
- Step 11: Audit Logging
- Step 12: Debugging with permission:why
- Testing Everything
- What We Built
- Comparison with Spatie
- Conclusion
๐ฏ What We're Building
Imagine TaskFlow, a project-management SaaS where:
- ๐ข Multiple companies (tenants) use the same app
- ๐ฅ Each company has its own users with different roles
- ๐ณ Roles follow a hierarchy (admin inherits editor's permissions)
- ๐ Users can only edit their own tasks (contextual rule)
- โฑ๏ธ Contractors get time-limited access
- ๐ Every permission change is audited
- ๐ When something breaks, we can explain why access was denied
This is exactly the kind of authorization system that's painful to build by hand. Let's see how a modern package makes it almost declarative.
โ Prerequisites
- PHP 8.2+
- Laravel 10, 11, 12, or 13
- Basic understanding of Laravel (models, migrations, middleware)
๐ฆ Step 1: Installation
composer require hosseinhezami/laravel-permission-manager
Publish and run migrations:
php artisan vendor:publish --provider="HosseinHezami\PermissionManager\PermissionManagerServiceProvider" --tag="config"
php artisan vendor:publish --provider="HosseinHezami\PermissionManager\PermissionManagerServiceProvider" --tag="migrations"
php artisan migrate
This creates 14 tables, including roles, permissions, user_permissions, role_inherits, teams, team_user, permission_conditions, and permission_audits.
Add the trait to your User model:
<?php
namespace App\Models;
use HosseinHezami\PermissionManager\Traits\PermissionTrait;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
use PermissionTrait;
}
โ Done. The foundation is ready.
๐ณ Step 2: Designing the Role Hierarchy
Instead of creating dozens of flat roles, we build a hierarchy so permissions cascade naturally.
<?php
use HosseinHezami\PermissionManager\Models\Role;
// Create roles from lowest to highest
$viewer = Role::create(['name' => 'Viewer', 'slug' => 'viewer']);
$editor = Role::create(['name' => 'Editor', 'slug' => 'editor']);
$admin = Role::create(['name' => 'Admin', 'slug' => 'admin']);
// Build the hierarchy: admin โ editor โ viewer
$editor->inheritFrom('viewer');
$admin->inheritFrom('editor');
Now assign permissions at each level:
// Viewer: read-only
$viewer->assignPermission(['projects.view', 'tasks.view']);
// Editor: can create and edit (inherits viewer's permissions)
$editor->assignPermission(['tasks.create', 'tasks.edit']);
// Admin: full control (inherits editor + viewer)
$admin->assignPermission(['projects.create', 'projects.delete', 'members.manage']);
The magic: a user with the admin role automatically gets every permission from editor and viewer โ no duplication.
$admin->hasPermissionTo('tasks.view'); // โ
true (inherited from viewer)
$admin->hasPermissionTo('tasks.edit'); // โ
true (inherited from editor)
$admin->hasPermissionTo('projects.delete'); // โ
true (direct)
๐ก๏ธ The package also includes cycle detection, so
A โ B โ Athrows aCyclicRoleInheritanceExceptioninstead of crashing your app.
Visualize it anytime:
php artisan permission:tree
๐ข Step 3: Creating Teams (Tenants)
Each company in TaskFlow is a team:
<?php
use HosseinHezami\PermissionManager\Models\Team;
$acme = Team::createTeam(['name' => 'Acme Corp', 'slug' => 'acme']);
$globex = Team::createTeam(['name' => 'Globex Inc', 'slug' => 'globex']);
๐ฅ Step 4: Team-Scoped Role Assignment
Here's where multi-tenancy shines. A user can hold different roles in different teams:
<?php
use App\Models\User;
$sarah = User::create([
'name' => 'Sarah Connor',
'email' => 'sarah@acme.com',
'password' => bcrypt('secret'),
]);
// Sarah joins both companies
$sarah->joinTeam($acme);
$sarah->joinTeam($globex);
// She's an ADMIN at Acme, but only an EDITOR at Globex
$sarah->assignRoleForTeam('admin', $acme);
$sarah->assignRoleForTeam('editor', $globex);
Check roles per team:
$sarah->hasRoleForTeam('admin', $acme); // โ
true
$sarah->hasRoleForTeam('admin', $globex); // โ false
$sarah->hasRoleForTeam('editor', $globex); // โ
true
Setting the Active Tenant
In your controllers, tell the package which team is active. The cleanest way is middleware:
// routes/web.php
Route::middleware(['auth', 'pm.team:header,X-Team-Id'])->group(function () {
// Every permission check inside is scoped to the team from the header
});
Or programmatically:
use HosseinHezami\PermissionManager\Facades\PermissionManager;
PermissionManager::setTeam($acme);
$sarah->hasPermissionTo('projects.delete'); // Evaluated in Acme's context
๐ Step 5: Direct Permissions & Explicit Deny
Real products always have exceptions. Two tools handle them elegantly.
Direct Permissions (grant without a role)
// Sarah needs to export reports, but no role grants it
$sarah->givePermissionTo('reports.export');
$sarah->hasDirectPermission('reports.export'); // โ
true
Explicit Deny (the "except this one" rule)
The CFO insists Sarah must never delete projects, even though her admin role allows it:
$sarah->denyPermissionTo('projects.delete');
// Now:
$sarah->hasPermissionTo('projects.create'); // โ
true (from admin role)
$sarah->hasPermissionTo('projects.delete'); // โ false (explicit deny wins)
โ๏ธ Rule of thumb: Deny always beats allow. This one rule eliminates the need for dozens of "exception roles."
๐ง Step 6: ABAC โ Conditional Permissions
Requirement: "Editors can edit tasks, but only their own, and only while the task is open."
This is contextual โ it depends on the specific task. That's ABAC (Attribute-Based Access Control):
<?php
use HosseinHezami\PermissionManager\Models\Permission;
use HosseinHezami\PermissionManager\Models\PermissionCondition;
$editPermission = Permission::findByRoute('tasks.edit');
PermissionCondition::create([
'permission_id' => $editPermission->id,
'name' => 'own-open-tasks-only',
'conditions' => [
'all' => [
['field' => 'user.id', 'operator' => '=', 'value' => 'resource.assignee_id'],
['field' => 'resource.status', 'operator' => '!=', 'value' => 'completed'],
],
],
]);
Now the check is resource-aware:
$myOpenTask = Task::create(['assignee_id' => $sarah->id, 'status' => 'open']);
$myDoneTask = Task::create(['assignee_id' => $sarah->id, 'status' => 'completed']);
$otherTask = Task::create(['assignee_id' => 999, 'status' => 'open']);
$sarah->canPermission('tasks.edit', $myOpenTask); // โ
true
$sarah->canPermission('tasks.edit', $myDoneTask); // โ false (completed)
$sarah->canPermission('tasks.edit', $otherTask); // โ false (not hers)
๐ The condition engine is whitelist-based โ no
eval(), no code injection. Only safe operators like=,!=,>,in,contains,exists.
โฑ๏ธ Step 7: Temporary Access for Contractors
A contractor needs billing access for 30 days:
$contractor = User::create([...]);
$contractor->givePermissionTo(
'billing.view',
'allow',
now()->addDays(30) // auto-expires
);
// Today:
$contractor->hasPermissionTo('billing.view'); // โ
true
// In 31 days (no code change needed):
$contractor->hasPermissionTo('billing.view'); // โ false
Clean up expired records weekly with a scheduled command:
php artisan permission:prune --days=7
๐ก๏ธ Step 8: Protecting Routes with Middleware
The package ships with an expressive Middleware DSL:
<?php
use Illuminate\Support\Facades\Route;
// Single permission
Route::get('/projects', [ProjectController::class, 'index'])
->middleware('pm:permission:projects.view');
// ANY of these (OR)
Route::get('/reports', [ReportController::class, 'index'])
->middleware('pm:permission:any:reports.view|reports.export');
// ALL of these (AND)
Route::post('/projects', [ProjectController::class, 'store'])
->middleware('pm:permission:all:projects.view|projects.create');
// Role-based
Route::get('/members', [MemberController::class, 'index'])
->middleware('pm:role:admin');
// Combined: must be admin AND have the permission
Route::delete('/projects/{project}', [ProjectController::class, 'destroy'])
->middleware(['pm:role:admin', 'pm:permission:projects.delete']);
Unauthorized users automatically receive a 403.
๐จ Step 9: Blade Directives for the UI
Show only what users can actually do:
{{-- resources/views/projects/show.blade.php --}}
@role('admin')
<a href="{{ route('members.index') }}" class="btn">Manage Members</a>
@endrole
@canpermission('tasks.edit', $task)
<button @click="editTask({{ $task->id }})">Edit Task</button>
@endcanpermission
@hasanyrole(['admin', 'editor'])
<div class="editor-toolbar">...</div>
@endhasanyrole
@unlesspermission('projects.delete')
<span class="text-muted">Deleting is disabled for your account.</span>
@endunlesspermission
No more scattered @if ($user->is_admin) logic.
๐งฉ Step 10: The Complete Controller
Here's everything working together in one controller:
<?php
namespace App\Http\Controllers;
use App\Models\Task;
use App\Models\Team;
use HosseinHezami\PermissionManager\Facades\PermissionManager;
use Illuminate\Http\Request;
class TaskController extends Controller
{
public function update(Request $request, string $teamSlug, Task $task)
{
// 1. Resolve and set the active tenant
$team = Team::where('slug', $teamSlug)->firstOrFail();
PermissionManager::setTeam($team);
$user = $request->user();
// 2. Role check within the tenant
if (! $user->hasAnyRole(['admin', 'editor'])) {
abort(403, 'You need an editor role in this team.');
}
// 3. Contextual (ABAC) check against the resource
if (! $user->canPermission('tasks.edit', $task)) {
abort(403, 'You can only edit your own open tasks.');
}
// 4. Perform the update
$task->update($request->validated());
return response()->json(['status' => 'updated']);
}
}
Three layers of authorization โ tenant, role, and context โ in under 10 lines.
๐ Step 11: Audit Logging
Every mutation is recorded automatically:
$sarah->assignRole('admin'); // logged
$sarah->denyPermissionTo('projects.delete'); // logged
$admin->assignPermission('users.*'); // logged
Query the trail:
use HosseinHezami\PermissionManager\Models\PermissionAudit;
// Who granted what, recently?
PermissionAudit::latest()->limit(20)->get();
// Everything a specific admin did
PermissionAudit::byActor($adminId)->get();
// Only grants
PermissionAudit::action('granted')->get();
Each record stores the actor, action, subject, IP address, user agent, and JSON metadata โ perfect for compliance audits.
๐ Step 12: Debugging with permission:why
Support ticket: "Sarah can't delete projects!" Instead of guessing, ask the engine:
php artisan permission:why 42 projects.delete
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Permission Decision Explanation โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
User: 42 (Sarah Connor)
Ability: projects.delete
โ DENIED
Reason: explicit_user_deny
Source: direct_permission
Details:
- matched_pattern: projects.delete
- permission_id: 15
Resolution chain:
โ Role 'admin' allows projects.* (would grant)
โ User has explicit DENY on projects.delete
โ Final decision: DENY (deny wins)
Mystery solved in 2 seconds. There's also a health check:
php artisan permission:doctor
It detects orphan permissions, cyclic inheritance, duplicates, expired grants, and cache inconsistencies.
๐งช Testing Everything
The package ships with testing helpers so your own test suite stays clean:
<?php
use HosseinHezami\PermissionManager\Testing\InteractsWithPermissions;
use HosseinHezami\PermissionManager\Testing\PermissionAssertions;
class TaskAuthorizationTest extends TestCase
{
use InteractsWithPermissions, PermissionAssertions;
public function test_editor_can_edit_own_open_task(): void
{
$editor = $this->actingAsRole(['editor']);
$task = Task::create(['assignee_id' => $editor->id, 'status' => 'open']);
$this->assertCanPermission($editor, 'tasks.edit', $task);
}
public function test_editor_cannot_edit_completed_task(): void
{
$editor = $this->actingAsRole(['editor']);
$task = Task::create(['assignee_id' => $editor->id, 'status' => 'completed']);
$this->assertCannotPermission($editor, 'tasks.edit', $task);
}
public function test_viewer_gets_403_on_create(): void
{
$this->actingAsRole(['viewer']);
$this->postJson('/projects')->assertStatus(403);
}
}
The package itself is backed by 141 passing tests and 226 assertions, all running on isolated SQLite in-memory databases.
๐ What We Built
In one tutorial, we implemented:
| Layer | Feature |
|---|---|
| Hierarchy |
admin โ editor โ viewer inheritance |
| ๐ข Tenancy | Team-scoped roles (admin at Acme, editor at Globex) |
| ๐ Exceptions | Direct permissions + explicit deny |
| ๐ง Context | ABAC conditions (own + open tasks only) |
| โฑ๏ธ Time | Auto-expiring contractor access |
| ๐ก๏ธ Routes | Middleware DSL (any / all / not) |
| ๐จ UI | 12+ Blade directives |
| ๐ Compliance | Automatic audit logging |
| ๐ Debugging |
permission:why + permission:doctor
|
That's a complete enterprise authorization stack โ without writing a single custom policy class.
composer require hosseinhezami/laravel-permission-manager
๐ Comparison with Spatie
For those evaluating options, here's how the two packages compare:
| Feature | Laravel Permission Manager | Spatie Permission |
|---|---|---|
| RBAC | โ | โ |
| Direct Permissions | โ | โ |
| Teams / Multi-Tenancy | โ | โ |
| Wildcard Permissions | โ (advanced + negation) | โ |
| Explicit Allow/Deny | โ | โ |
| Role Hierarchy | โ Multi-level + cycle detection | โ |
| Temporary Permissions | โ Auto-expiry | โ |
| ABAC / Conditions | โ JSON engine (no eval) | โ |
| Audit Logging | โ Built-in | โ |
| Explain API | โ | โ |
| CLI 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 remains an excellent, battle-tested choice for standard RBAC. Laravel Permission Manager adds the enterprise layers โ hierarchy, deny rules, ABAC, tenancy depth, auditing, and debugging โ that Spatie doesn't cover.
๐ฌ Conclusion
Authorization in a multi-tenant SaaS isn't just "roles and permissions." It's hierarchies, exceptions, contexts, time limits, audits, and debugging โ all at once.
Building that by hand means months of scattered policies and middleware. With the right package, it becomes a set of declarative, testable, auditable rules.
If you're building a SaaS or any app with complex access rules, give Laravel Permission Manager a try:
composer require hosseinhezami/laravel-permission-manager
And if you found this guide useful, a โญ on GitHub helps a lot!
๐ GitHub Repository .
๐ฆ Packagist ยท
Tags: #laravel #php #saas #multitenancy #authorization #rbac #abac #tutorial #webdev #opensource #security #backend
Top comments (0)