DEV Community

Cover image for Building a Multi-Tenant SaaS Authorization System in Laravel: A Complete Step-by-Step Guide (RBAC + ABAC + Teams)
Hossein Hezami
Hossein Hezami

Posted on

Building a Multi-Tenant SaaS Authorization System in Laravel: A Complete Step-by-Step Guide (RBAC + ABAC + Teams)

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

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

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

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

โœ… 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');
Enter fullscreen mode Exit fullscreen mode

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

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

๐Ÿ›ก๏ธ The package also includes cycle detection, so A โ†’ B โ†’ A throws a CyclicRoleInheritanceException instead of crashing your app.

Visualize it anytime:

php artisan permission:tree
Enter fullscreen mode Exit fullscreen mode

๐Ÿข 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']);
Enter fullscreen mode Exit fullscreen mode

๐Ÿ‘ฅ 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);
Enter fullscreen mode Exit fullscreen mode

Check roles per team:

$sarah->hasRoleForTeam('admin', $acme);    // โœ… true
$sarah->hasRoleForTeam('admin', $globex);  // โŒ false
$sarah->hasRoleForTeam('editor', $globex); // โœ… true
Enter fullscreen mode Exit fullscreen mode

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

Or programmatically:

use HosseinHezami\PermissionManager\Facades\PermissionManager;

PermissionManager::setTeam($acme);
$sarah->hasPermissionTo('projects.delete'); // Evaluated in Acme's context
Enter fullscreen mode Exit fullscreen mode

๐Ÿ”’ 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
Enter fullscreen mode Exit fullscreen mode

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

โš–๏ธ 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'],
        ],
    ],
]);
Enter fullscreen mode Exit fullscreen mode

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

๐Ÿ”’ 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
Enter fullscreen mode Exit fullscreen mode

Clean up expired records weekly with a scheduled command:

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

๐Ÿ›ก๏ธ 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']);
Enter fullscreen mode Exit fullscreen mode

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

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

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

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

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
Enter fullscreen mode Exit fullscreen mode
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  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)
Enter fullscreen mode Exit fullscreen mode

Mystery solved in 2 seconds. There's also a health check:

php artisan permission:doctor
Enter fullscreen mode Exit fullscreen mode

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

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

๐Ÿ“Š 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
Enter fullscreen mode Exit fullscreen mode

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)