← Back to list

Building a Multi-Tenant Role System in Laravel with Dynamic Policies

Practical guide to tenant-scoped roles and policies that respect the active tenant.

Ilyas Kazi · 2025-10-16 02:11 · 69 claps · 6.2 min read paywalled
#laravel #spatie #laravel-permission #php #multitenancy
Open on Medium ↗

Building a Multi-Tenant Role System in Laravel with Dynamic Policies

Practical guide to tenant-scoped roles and policies that respect the active tenant.

Multi-Tenant Role System in Laravel

Multi-Tenant Role System in Laravel

This walkthrough shows a pragmatic, production-friendly approach to multi-tenant authorization using **Spatie’s multitenancy package plus [Spatie’s permission package](https://github.com/spatie/laravel-permission)**. You’ll learn how to:

✔ Install both packages. ✔ Scope roles/permissions to a tenant (per-tenant roles) while optionally keeping some global roles. ✔ Seed tenant roles and keep permission caches tenant-aware. ✔ Implement dynamic policies and gate definitions that check permissions in the context of the current tenant.

Not a member on medium.com? Read the full post by Clicking here!

This post assumes a single Laravel application with tenant awareness handled by spatie/laravel-multitenancy (database or single-db + tenant identifier), and the well-known spatie/laravel-permission package for roles & permissions.

Why this pattern?

In multi-tenant apps you normally want authorization decisions to respect tenant boundaries: an “admin” of Tenant A shouldn’t be able to manage Tenant B. At the same time some roles/permissions might remain global (e.g., super-admin). The simplest robust approach is to attach a tenant_id (or team_id) to roles/permissions (or to the pivot tables) and make permission checks in the context of the currently active tenant.

Spatie’s multitenancy package gives you reliable tenant switching and hooks; Spatie’s permission package provides the ACL primitives. They don’t wire tenant scoping together out of the box, so we’ll add a small integration layer.

Prereqs

  • Laravel (11+ recommended).
  • spatie/laravel-multitenancy installed and configured (you must be able to resolve the “current tenant” for a request).
  • spatie/laravel-permission installed and configured.

1) Install packages

composer require spatie/laravel-multitenancy
composer require spatie/laravel-permission

php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider" --tag="config"
php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="migrations"

php artisan migrate

Note: Adjust published assets and migrations to your app as needed.

2) Decide how to scope roles/permissions

Two common approaches:

A. Role/permission rows belong to tenants Add a tenant_id column to roles, permissions and (optionally) pivot tables. Bind your Role/Permission models to a tenant_id global scope so queries are tenant-scoped by default.

B. Use a team/tenant pivot Keep roles and permissions global, but add tenant_id to the pivot tables (e.g., model_has_roles, model_has_permissions) so assignments are tenant scoped.

Which to pick depends on your data model and whether roles/permissions themselves need to differ between tenants. If tenants should define their own roles (e.g., “Editor (Acme)”), prefer A. If roles are identical across tenants and only assignments differ, B can be simpler. (Community discussion on syncing vs per-tenant roles is common — there’s no one-size-fits-all). GitHub

3) Example: Tenant-scoped Roles (Approach A)

Migration adjustments

Create migrations that add tenant_id to roles and permissions:

// database/migrations/xxxx_add_tenant_to_roles_permissions.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class AddTenantToRolesPermissions extends Migration
{
    public function up()
    {
        Schema::table('roles', function (Blueprint $table) {
            $table->unsignedBigInteger('tenant_id')->nullable()->index()->after('id');
        });

        Schema::table('permissions', function (Blueprint $table) {
            $table->unsignedBigInteger('tenant_id')->nullable()->index()->after('id');
        });

        // Also consider tenant_id on model_has_roles and model_has_permissions
        Schema::table('model_has_roles', function (Blueprint $table) {
            $table->unsignedBigInteger('tenant_id')->nullable()->index()->after('role_id');
        });
        Schema::table('model_has_permissions', function (Blueprint $table) {
            $table->unsignedBigInteger('tenant_id')->nullable()->index()->after('permission_id');
        });
    }

    public function down()
    {
        Schema::table('model_has_permissions', fn($t) => $t->dropColumn('tenant_id'));
        Schema::table('model_has_roles', fn($t) => $t->dropColumn('tenant_id'));
        Schema::table('permissions', fn($t) => $t->dropColumn('tenant_id'));
        Schema::table('roles', fn($t) => $t->dropColumn('tenant_id'));
    }
}

Note: If you make tenant_id non-nullable, you must create entries for global roles with tenant_id = null (or have a separate is_global flag).

Extend Spatie models

Create custom Role and Permission models so you can add a tenant scope.

// app/Models/Role.php
namespace App\Models;

use Spatie\Permission\Models\Role as SpatieRole;
use Illuminate\Database\Eloquent\Builder;

class Role extends SpatieRole
{
    protected static function booted()
    {
        static::addGlobalScope('tenant', function (Builder $builder) {
            $tenant = tenant(); // from spatie/laravel-multitenancy helper
            if ($tenant) {
                $builder->where('tenant_id', $tenant->id);
            } else {
                // optionally keep global roles (tenant_id NULL)
                $builder->whereNull('tenant_id');
            }
        });

        static::creating(function ($role) {
            $tenant = tenant();
            if ($tenant) {
                $role->tenant_id = $tenant->id;
            }
        });
    }
}

Likewise for Permission. Then, update config/permission.php to use your models:

'models' => [
    'permission' => App\Models\Permission::class,
    'role' => App\Models\Role::class,
],

4) Tenant-aware assignments

When assigning roles/permissions in code, also set tenant_id on the pivot row. You can override the attach logic or use events:

// when assigning a role to a user
$user->assignRole($role); // ensure $role->tenant_id matches tenant()

If using the default Spatie behavior, ensure the model_has_roles record includes the tenant id. You can override HasRoles::assignRole() in a trait wrapper to inject tenant id into the pivot. Example (sketch):

trait TenantHasRoles
{
    public function assignRole(...$roles)
    {
        $roles = collect($roles)->flatten()->map(function ($role) {
            return $this->getStoredRole($role);
        });

        $tenantId = tenant()?->id;

        foreach ($roles as $role) {
            $this->roles()->attach($role->id, ['tenant_id' => $tenantId, 'model_type' => static::class]);
        }

        app(\Spatie\Permission\PermissionRegistrar::class)->forgetCachedPermissions();
        return $this;
    }
}

Note: If you override, make sure to follow Spatie package internals and keep caches in sync.

5) Make permission cache tenant-aware

Spatie caches permissions for performance. In a tenant environment, the cache key must include the tenant id (or null for global). You can customize the cache key in PermissionRegistrar or before registering permissions clear and re-seed per tenant.

Example in a tenancy boot hook:

// in your Tenancy boot task (Spatie multitenancy provides tasks)
use Spatie\Permission\PermissionRegistrar;

app(PermissionRegistrar::class)->setCacheKey('spatie.permission.cache.tenant.' . (tenant()?->id ?? 'global'));
app(PermissionRegistrar::class)->forgetCachedPermissions();

Keeping the registrar cache keyed by tenant ensures permission checks consult correct scopes.

6) Dynamic Policies — AuthServiceProvider & tenant context

Policies can be written as usual, but policy methods need to rely on tenant-aware permission checks.

// app/Providers/AuthServiceProvider.php
public function boot()
{
    $this->registerPolicies();

    Gate::before(function ($user, $ability) {
        // super-admin across tenants
        if ($user->hasRole('super-admin')) {
            return true;
        }
    });

    // define ability -> permission name mapping if you want
    Gate::define('manage-project', function ($user, $project = null) {
        // check permission in tenant context
        return $user->hasPermissionTo('manage projects');
    });
}

Example Policy:

// app/Policies/ProjectPolicy.php
public function update(User $user, Project $project)
{
    // ensure same tenant (if projects are tenant-scoped)
    if ($project->tenant_id !== tenant()->id) {
        return false;
    }

    return $user->hasPermissionTo('edit projects');
}

Important: hasPermissionTo must resolve permissions for the current tenant context — because of the model scopes & tenant-aware caching above, it will check the tenant-scoped permissions.

7) Seeding roles & permissions per tenant

When onboarding a tenant (e.g., during tenant registration), seed default roles & permissions for that tenant:

// TenantOnboardingService.php
public function createDefaultRolesForTenant($tenant)
{
    tenancy()->initialize($tenant, function() use ($tenant) {
        // in the tenant context
        Role::create(['name' => 'admin', 'tenant_id' => $tenant->id]);
        Role::create(['name' => 'user', 'tenant_id' => $tenant->id]);

        Permission::create(['name' => 'create projects', 'tenant_id' => $tenant->id]);
        Permission::create(['name' => 'edit projects', 'tenant_id' => $tenant->id]);
        // assign perm to role using role model (scoped to tenant)
        $admin = Role::where('name', 'admin')->first();
        $admin->givePermissionTo('create projects', 'edit projects');
    });
}

This approach ensures each tenant has its own set of roles & permissions.

8) Global roles & hybrid model

If you need global roles (e.g., super-admin), create them with tenant_id = null. In your Role model global scope, allow whereNull('tenant_id') for the non-tenant case or make an exception for users with super-admin. When checking roles, first check global roles, then tenant roles.

Example Gate before-check (already shown) allows super-admin to bypass tenant checks.

9) Troubleshooting & gotchas

  • Cache: Forgetting to incorporate tenant id into Spatie’s permission cache is the most common pitfall — you’ll end up seeing wrong permissions across tenants. Make the cache key tenant-aware.
  • Global vs tenant roles: Decide early whether roles/permissions are per-tenant or global — migrating later is painful. There are community threads about syncing fixed roles across tenants; choose seeding or synchronization strategy if roles must be identical. GitHub
  • Model scoping: If you use global scopes on Role/Permission models, be careful when running CLI commands (seeders, artisan tinker) — you might need to temporarily disable scopes or initialize a tenant.
  • Third-party integrations: Filament, Nova, or other admin UI packages may assume global roles — adapt their user model config to be tenant-aware. (Community examples exist showing adaptations.)

10) Example: Put it all together — request lifecycle

  1. spatie/laravel-multitenancy resolves the tenant for the incoming request (middleware or tenant resolver).
  2. Tenant boot task sets the permission cache key and any tenant-specific service bindings.
  3. Your controllers/policies call $user->hasPermissionTo(...) or Gate::allows('ability') which resolves against tenant-scoped permissions/roles.
  4. Authorization is enforced per tenant; global super-admin still bypasses checks.

Checklist before production

✔ Make permission cache tenant-aware. ✔ Decide global vs tenant scoping policy and document it. ✔ Seed default roles/permissions during tenant onboarding. ✔ Test policy behavior with multi-tenant test cases (tenant A admin cannot affect tenant B). ✔ Audit UI/admin flows for tenancy awareness (role assignment, role management).

Conclusion

The approach above keeps things explicit and predictable: tenant data stays in tenant-scoped rows, caches are keyed by tenant, and policies use the tenant context. Spatie’s two packages provide excellent foundations — the integration layer (migrations, model scopes, cache key, seeding) is where you tune behavior to your product’s needs. For patterns and edge cases the community has many examples (seeding, team/pivot approaches) — pick the model that best matches whether roles themselves must differ across tenants or only assignments should.

➣ Follow me and subscribe to read such articles on Laravel.

[embed]Beyond Roles and Permissions — Designing a Context-Aware Authorization System in Laravel Multi-tenant, multi-context, multi-layer: the future of scalable access control in Laravel.medium.com

[embed]Building an Audit Trail System in Laravel — Every Change, Every Actor, Every Context Why audit logs are more than compliance — they’re observability for humans.medium.com


메타데이터
post_id
129261d879ec
slug
building-a-multi-tenant-role-system-in-laravel-with-dynamic-policies-129261d879ec
url
https://medium.com/@ilyaskazi/building-a-multi-tenant-role-system-in-laravel-with-dynamic-policies-129261d879ec
canonical_url
https://medium.com/@ilyaskazi/building-a-multi-tenant-role-system-in-laravel-with-dynamic-policies-129261d879ec
author_url
https://medium.com/@ilyaskazi
status
ok
fetched_at
2026-07-16 18:55:29