Role Management
SaaS4Builders uses Spatie Permission with its teams feature to provide tenant-scoped role-based access control. The system ships with three built-in roles and supports custom roles that tenants can create to match their organizational structure.
Built-in Roles
Three roles are seeded by default and available to all tenants:
Owner — The user who created the tenant. Has full control over every aspect of the organization: team management, billing, settings, and tenant deletion. There is exactly one owner per tenant. Ownership can only be transferred via the team.transfer_ownership permission.
Admin — A trusted team member with broad management capabilities. Admins can invite and remove members, manage billing and settings, and create custom roles. They cannot delete the tenant or transfer ownership.
Member — A standard team member with minimal permissions. Members can view billing information but cannot make changes to the team, billing, or settings. This is the default role assigned when no other role is specified.
Permissions Matrix
The seeder (backend/database/seeders/RolesAndPermissionsSeeder.php) defines 10 tenant-scoped permissions under the web guard:
| Permission | Description | Owner | Admin | Member |
|---|---|---|---|---|
tenant.update | Update tenant name and settings | ✓ | ✓ | — |
tenant.delete | Delete the tenant permanently | ✓ | — | — |
team.invite | Send team invitations | ✓ | ✓ | — |
team.remove | Remove team members | ✓ | ✓ | — |
team.manage | General team management | ✓ | ✓ | — |
team.transfer_ownership | Transfer ownership to another member | ✓ | — | — |
billing.view | View subscription, invoices, usage | ✓ | ✓ | ✓ |
billing.manage | Create/cancel subscriptions, manage payment methods | ✓ | ✓ | — |
settings.view | View tenant settings | ✓ | ✓ | — |
roles.manage | Create, update, delete custom roles | ✓ | ✓ | — |
Here is the relevant portion of the seeder that assigns permissions to built-in roles:
$ownerRole = Role::findOrCreate('owner', 'web');
$ownerRole->givePermissionTo([
'tenant.update', 'tenant.delete',
'team.invite', 'team.remove', 'team.manage', 'team.transfer_ownership',
'billing.view', 'billing.manage',
'settings.view', 'roles.manage',
]);
$adminRole = Role::findOrCreate('admin', 'web');
$adminRole->givePermissionTo([
'tenant.update',
'team.invite', 'team.remove', 'team.manage',
'billing.view', 'billing.manage',
'settings.view', 'roles.manage',
]);
$memberRole = Role::findOrCreate('member', 'web');
$memberRole->givePermissionTo([
'billing.view',
]);
Custom roles can be assigned any combination of these 10 permissions. When you create a custom role, you select from the same permission pool.
Role Assignment Rules
The ChangeMemberRole action enforces strict business rules when changing a member's role:
- Cannot change your own role — prevents users from escalating their own permissions or accidentally locking themselves out. Returns
CANNOT_CHANGE_OWN_ROLE. - Cannot change the owner's role — the owner role is permanent. To change who owns the tenant, use ownership transfer. Returns
CANNOT_CHANGE_OWNER_ROLE. - Cannot assign the owner role — the "owner" role is never available in the role assignment dropdown. Only ownership transfer can move the owner role. Returns error if attempted.
- Requires
roles.managepermission — only users with this permission (owners and admins by default) can change other members' roles. ReturnsINSUFFICIENT_PERMISSIONS.
team.transfer_ownership permission. You cannot promote a member to "owner" through the role change endpoint — only through a dedicated ownership transfer flow.Member Removal Rules
The RemoveMember action has similar restrictions:
- Cannot remove yourself — use a separate "leave team" flow if needed. Returns
CANNOT_REMOVE_SELF. - Cannot remove the owner — the owner can only leave by transferring ownership first. Returns
CANNOT_REMOVE_OWNER. - Requires
team.removepermission — only owners and admins can remove members. - Hierarchy enforcement — a non-owner cannot remove someone with the same role level. Only the owner can remove an admin.
When a member is removed:
- Their Spatie roles are cleared within the tenant context
- They are detached from the tenant via the pivot table
- If the user has no remaining tenant memberships and is not a platform admin, the user account is deleted (orphan cleanup)
- A
TeamMemberRemovedevent is dispatched, triggering seat quantity sync
Custom Tenant-Scoped Roles
Beyond the three built-in roles, tenants can create custom roles with specific permission sets to match their organizational needs.
Creating Custom Roles
Create a custom role via POST /api/v1/tenant/{tenantId}/roles:
{
"name": "billing-manager",
"permissions": ["billing.view", "billing.manage"]
}
Validation rules:
- The role name must be unique within the tenant
- Reserved names (
owner,admin,member) cannot be used for custom roles - At least one permission must be assigned
- Only valid web-guard permissions are accepted
Custom roles are stored in the Spatie roles table with the tenant_id set to the creating tenant's ID. This scopes them to a single tenant — other tenants cannot see or use them.
Updating Custom Roles
Update a role's name or permissions via PATCH /api/v1/tenant/{tenantId}/roles/{roleId}:
{
"name": "finance-manager",
"permissions": ["billing.view", "billing.manage", "settings.view"]
}
Restrictions:
- Built-in roles (
owner,admin,member) cannot be renamed - Reserved names cannot be used as the new name
- Permission changes take effect immediately for all users with that role (Spatie's cache is cleared)
Deleting Custom Roles
Delete a custom role via DELETE /api/v1/tenant/{tenantId}/roles/{roleId}.
Behavior:
- Built-in roles cannot be deleted
- All users currently assigned the deleted role are automatically reassigned to the
memberrole - The role record and its permission associations are removed
member role. No user is ever left without a role in the tenant.Spatie Permissions Integration
Team Scoping
The Spatie Permission package is configured with teams => true and team_foreign_key => 'tenant_id'. This means:
- The
rolestable has atenant_idcolumn - Built-in roles have
tenant_id = NULL(global templates, available to all tenants) - Custom roles have
tenant_idset (scoped to the creating tenant) - The
model_has_rolestable usesteam_id(which maps totenant_id) to scope role assignments - Role queries resolve both global (
tenant_id IS NULL) and tenant-specific roles
Role Resolution with ResolvesTeamRole
Actions that need to check a user's role or permissions use the ResolvesTeamRole trait, which wraps all checks in TeamContext::run() for safe scoping:
trait ResolvesTeamRole
{
private function getCurrentRole(Tenant $tenant, User $user): ?string
{
return TeamContext::run($tenant->id, function () use ($user): ?string {
$user->load('roles');
$role = $user->roles->first();
return $role?->name;
});
}
private function userCan(Tenant $tenant, User $user, string $permission): bool
{
return TeamContext::run($tenant->id, function () use ($user, $permission): bool {
$user->load('roles', 'permissions');
return $user->hasPermissionTo($permission, 'web');
});
}
}
The getCurrentRole() method explicitly reloads the roles relationship within TeamContext to ensure proper Spatie team scoping — this is important because cached role relationships may reference a different tenant context.
API Endpoints
Role Management
All role management endpoints require the roles.manage permission and tenant context middleware (auth:sanctum, tenant.resolve, tenant.member, onboarding.complete).
| Method | Path | Description |
|---|---|---|
GET | /api/v1/tenant/{tenantId}/roles | List all roles (built-in + custom) with permissions and user counts |
GET | /api/v1/tenant/{tenantId}/roles/permissions | List all available web-guard permission keys |
POST | /api/v1/tenant/{tenantId}/roles | Create a custom role |
PATCH | /api/v1/tenant/{tenantId}/roles/{roleId} | Update a role's name and/or permissions |
DELETE | /api/v1/tenant/{tenantId}/roles/{roleId} | Delete a custom role (reassigns users to "member") |
Role response structure:
{
"id": 4,
"name": "billing-manager",
"guard_name": "web",
"tenant_id": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"is_builtin": false,
"permissions": ["billing.view", "billing.manage"],
"users_count": 3,
"created_at": "2026-03-20T10:00:00.000000Z",
"updated_at": "2026-03-20T10:00:00.000000Z"
}
Member Role Endpoints
These endpoints manage role assignment for individual team members:
| Method | Path | Description |
|---|---|---|
GET | /api/v1/tenant/{tenantId}/team/roles | List assignable roles (simplified: id + name) |
PATCH | /api/v1/tenant/{tenantId}/team/members/{memberId}/role | Change a member's role |
Change role request:
{
"role_id": 3
}
The role_id must reference a valid web-guard role that is either global (tenant_id IS NULL) or belongs to the current tenant. The owner role cannot be assigned through this endpoint.
Frontend Integration
The frontend provides composables and components for role management:
Composables
useTenantRoles()— Fetches the list of roles for the current tenant viauseAuthenticatedAsyncData, including both built-in and custom rolesuseTenantRoleActions()— Mutation composable forcreateRole(),updateRole(),deleteRole(), andfetchPermissions()useTeamMembers()— Provides role-related computed properties (canChangeRoles,isManager) and theenrichMember()function that addscanRoleBeChangedflags
Components
TeamRoleSelector— A dropdown (USelect) for selecting a role, with role names translated viauseRoleLabel()TeamMemberCard— Displays the role badge (read-only for owners, dropdown selector for changeable roles)
Types
The frontend defines rich types for role management:
type TenantRole = {
id: number
name: string
guardName: string
tenantId: string | null
isBuiltin: boolean
permissions: string[]
usersCount: number
createdAt: string
updatedAt: string
}
type CreateTenantRoleInput = {
name: string
permissions: string[]
}
type UpdateTenantRoleInput = {
name?: string
permissions?: string[]
}
The ManageableRole type ('admin' | 'member') restricts the roles available for assignment — owner is never an option in the UI.
Invitations
Team invitation flow: token-based security, invitation lifecycle, email notifications, public and protected API endpoints, and error handling.
Seat-Based Billing Integration
How team size connects to subscription billing: seat quotas, entitlement checks, no-subscription modes, automatic seat syncing, and frontend awareness.