Article

Multi-Tenant RBAC in Laravel: Team-Scoped Permissions with Spatie and Teamwork

D

Dinesh Wijethunga

May 17, 2026 5 min readBeginner 193 views
πŸ“

A ride-booking platform has a permission problem that most tutorials don't cover. It's not just "admins can do X, customers can only do Y." An agency admin can manage their own drivers and vehicles. An agency subadmin can view their agency's bookings but not modify driver accounts. A system admin can do everything, globally.

The same permission (view bookings) means different things depending on who is asking and which team they belong to. That's multi-tenant RBAC β€” and it needs two packages working together.

The Packages

  • spatie/laravel-permission with teams mode enabled β€” adds a team_id column to every role and permission assignment
  • mpociot/teamwork β€” provides the Team model, currentTeam, attachTeam(), and switchTeam() methods

Role Design

// app/Enums/RoleEnum.php

enum RoleEnum: int
{
    case sys_admin      = 1;   // Global, bypasses all checks
    case sys_subadmin   = 2;   // Global, limited permissions
    case agent_admin    = 3;   // Creates and owns an agency team
    case agent_subadmin = 4;   // Joins an existing agency team
    case dealer         = 5;   // Creates and owns a dealer team
    case customer       = 6;   // Joins the shared Customers team
    case driver         = 7;   // Joins the shared Drivers team
    case fleet_driver   = 8;   // Joins their fleet owner's team

    public static function isTeamOwnerRole(array $roles): bool
    {
        $ownerRoles = [
            self::agent_admin->name, self::agent_admin->value,
            self::dealer->name,      self::dealer->value,
        ];
        return (bool) array_intersect($roles, $ownerRoles);
    }

    public static function isSubRole(array $roles): bool
    {
        $subRoles = [
            self::agent_subadmin->name, self::agent_subadmin->value,
            self::fleet_driver->name,   self::fleet_driver->value,
        ];
        return (bool) array_intersect($roles, $subRoles);
    }
}

Eight roles. Two of them (agent_admin, dealer) create a new team when they register. Two (agent_subadmin, fleet_driver) join their manager's existing team. The rest join shared global teams (Customers, Drivers, System Admins).

Enabling Teams in Spatie

// config/permission.php
return [
    'teams'            => true,
    'team_foreign_key' => 'team_id',
];

With teams => true, Spatie adds a team_id column to the model_has_roles and model_has_permissions pivot tables. A role assignment is now (user_id, role_id, team_id) β€” not just (user_id, role_id). The same user can be agent_admin in one team and have no role in another.

Assigning Roles on Registration

The SaveUser action handles team resolution and role assignment in a single transaction:

// app/Actions/SaveUser.php

private function resolveTeam(array $roles, ?string $teamType, array $data, User $user): Team
{
    // agent_admin, dealer β†’ CREATE a new team
    if (RoleEnum::isTeamOwnerRole($roles)) {
        return Team::create([
            'name'      => $data['team_name'] ?? $user->name . "'s Team",
            'owner_id'  => $user->id,
            'team_type' => $teamType,
        ]);
    }

    // agent_subadmin, fleet_driver β†’ JOIN admin's existing team
    if (RoleEnum::isSubRole($roles)) {
        return Team::where('owner_id', auth()->id())->firstOrFail();
    }

    // driver β†’ shared Drivers team
    if ($teamType === 'driver') {
        return Team::firstOrCreate(
            ['name' => 'Drivers', 'team_type' => 'driver'],
            ['owner_id' => null]
        );
    }

    // customer β†’ shared Customers team
    return Team::firstOrCreate(
        ['name' => 'Customers', 'team_type' => 'customer'],
        ['owner_id' => null]
    );
}

// After resolving the team:
$user->attachTeam($team);
$user->switchTeam($team);
setPermissionsTeamId($team->id);  // Spatie: scope all role checks to this team
$user->assignRole($roles[0]);

The order matters. setPermissionsTeamId() must be called before assignRole() β€” otherwise Spatie saves the role with team_id = null, which is a global assignment.

The TeamsPermission Middleware

Before every authenticated request, Spatie needs to know which team to scope permission checks against. A middleware handles this:

// app/Http/Middleware/TeamsPermission.php

class TeamsPermission
{
    public function handle(Request $request, Closure $next): Response
    {
        $user = auth('api')->user();

        if ($user && !$user->hasRole(RoleEnum::sys_admin->name)) {
            $teamId = $user->currentTeam?->id;
            if ($teamId) {
                setPermissionsTeamId($teamId);
            }
        }

        return $next($request);
    }
}

sys_admin is intentionally excluded. The Gate bypass in AppServiceProvider handles them globally:

// app/Providers/AppServiceProvider.php

Gate::before(function ($user, string $ability) {
    if ($user->hasRole(RoleEnum::sys_admin->name)) {
        return true;
    }
});

sys_admin bypasses all checks. Everyone else has permissions scoped to their current team.

Using Permissions in Controllers

With the middleware in place, can() and @can work as expected β€” but now they check team-scoped permissions:

// Controller method
public function index(): JsonResponse
{
    $this->authorize('view_drivers');
    // ...
}

// Route middleware
Route::middleware(['auth:api', 'permission:manage_vehicles'])->group(function () {
    Route::apiResource('vehicles', VehicleController::class);
});

An agent_admin checking view_drivers only sees it true if they were assigned that permission on their team. The same check for a customer returns false β€” they have no such permission on the Customers team.

Testing RBAC

// tests/Feature/UserRoleAccessTest.php

public function test_agent_admin_cannot_access_system_users(): void
{
    $user = $this->createUserWithRole('agent_admin');
    Passport::actingAs($user, ['*'], 'api');

    $this->getJson('/api/admin/users')->assertStatus(403);
}

public function test_sys_admin_can_access_everything(): void
{
    $user = $this->createUserWithRole('sys_admin');
    Passport::actingAs($user, ['*'], 'api');

    $this->getJson('/api/admin/users')->assertOk();
}

Passport::actingAs() creates a token-scoped session. The test database seeds permissions and roles before each test, ensuring clean state. SQLite :memory: makes the full suite run in seconds.

D

Dinesh Wijethunga

Senior Full Stack Developer Β· Building SaaS products & teaching Laravel/React Β· 10+ years experience Β· Founder of Orion360 Β· Based in Dubai, UAE.

Reviews & Ratings

Sign in to leave a review.

Comments(0)

Guest comments are held for moderation.