Multi-Tenant RBAC in Laravel: Team-Scoped Permissions with Spatie and Teamwork
Dinesh Wijethunga
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
teamsmode enabled β adds ateam_idcolumn to every role and permission assignment - mpociot/teamwork β provides the
Teammodel,currentTeam,attachTeam(), andswitchTeam()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.
Reviews & Ratings
Sign in to leave a review.
