Teams Overview
SaaS4Builders ships a complete team management system that connects users to tenants through role-based membership. A tenant represents the customer organization (see Multi-Tenancy Overview), and the team is the set of users attached to it. Members join via invitations, receive roles that control their permissions, and their count integrates directly with seat-based billing.
Teams and Tenants
Users and tenants are linked through a many-to-many pivot table (tenant_user), with each membership recording when the user joined:
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('joined_at')
->withTimestamps();
}
public function tenants(): BelongsToMany
{
return $this->belongsToMany(Tenant::class)
->withPivot('joined_at')
->withTimestamps();
}
Every tenant also has an owner — the user who created it during onboarding:
public function owner(): BelongsTo
{
return $this->belongsTo(User::class, 'owner_id');
}
USER_BELONGS_TO_ANOTHER_TENANT error.Built-in Roles
The system seeds three built-in roles that define the default permission hierarchy:
enum TeamRole: string
{
case Owner = 'owner';
case Admin = 'admin';
case Member = 'member';
public static function isBuiltIn(string $roleName): bool
{
return self::tryFrom($roleName) !== null;
}
}
- Owner — Full control over the tenant. Can delete the tenant, transfer ownership, and manage all aspects of the team and billing. There is exactly one owner per tenant.
- Admin — Can invite and remove members, manage billing, change roles, and update tenant settings. Cannot delete the tenant or transfer ownership.
- Member — Read-only access to billing information. Cannot invite or remove users, manage settings, or perform administrative actions.
Permissions Matrix
Each role receives a specific set of permissions via the Spatie Permission package. The seeder defines:
| Permission | Description | Owner | Admin | Member |
|---|---|---|---|---|
tenant.update | Update tenant name, settings | ✓ | ✓ | — |
tenant.delete | Delete the tenant | ✓ | — | — |
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 and invoices | ✓ | ✓ | ✓ |
billing.manage | Manage subscriptions and payment methods | ✓ | ✓ | — |
settings.view | View tenant settings | ✓ | ✓ | — |
roles.manage | Create, update, delete custom roles | ✓ | ✓ | — |
Beyond these three built-in roles, tenants can create custom roles with any combination of these permissions. See Role Management for details.
Spatie Permissions Integration
Roles and permissions are managed through the Spatie Permission package with its teams feature enabled. This means every role assignment is scoped to a specific tenant — a user can have different roles in different tenants (relevant for future multi-tenant support).
The configuration in backend/config/permission.php sets:
'teams' => true,
'team_foreign_key' => 'tenant_id',
Built-in roles (owner, admin, member) are seeded with tenant_id = NULL, making them global templates available to all tenants. Custom roles are created with a specific tenant_id, scoping them to a single tenant.
Permission checks always use hasPermissionTo() rather than role name comparisons, which means custom roles work transparently:
// ✅ Works for both built-in and custom roles
$user->hasPermissionTo('team.invite', 'web');
// ❌ Avoid — breaks with custom roles
$user->hasRole('admin');
TeamContext
When performing role or permission operations, you must set the correct Spatie team context so that role lookups are scoped to the right tenant. The TeamContext helper ensures this happens safely, with automatic restoration of the previous context:
final class TeamContext
{
/**
* Execute a callback within a specific tenant/team context.
*/
public static function run(?string $tenantId, Closure $callback): mixed
{
$previousTeamId = getPermissionsTeamId();
try {
setPermissionsTeamId($tenantId);
return $callback();
} finally {
setPermissionsTeamId($previousTeamId);
}
}
}
Usage in an action:
TeamContext::run($tenant->id, function () use ($user) {
$user->assignRole('member');
});
TeamContext::run() when assigning or checking roles and permissions. Direct calls to setPermissionsTeamId() without restoration risk context leaks in long-lived processes like queue workers and Laravel Octane.Architecture Overview
The team feature follows the project's modular domain-driven architecture:
backend/app/
├── Domain/Team/
│ ├── Contracts/ # InvitationNotifierInterface
│ ├── Enums/ # TeamRole, InvitationStatus
│ ├── Exceptions/ # TeamException (domain errors)
│ ├── Support/ # TeamContext, ResolvesTeamRole
│ └── ValueObjects/ # InvitationToken
├── Application/Team/
│ ├── Actions/ # InviteTeamMember, AcceptInvitation, ...
│ ├── Queries/ # ListTeamMembers, GetTeamStats, ...
│ └── DTO/ # CreateInvitationData, ChangeMemberRoleData, ...
├── Infrastructure/Team/
│ └── Services/ # InvitationNotifier (email delivery)
└── Http/
├── Controllers/Api/V1/Team/
│ ├── InvitationController.php
│ ├── TeamMemberController.php
│ └── TenantRoleController.php
├── Requests/Api/V1/Team/
│ ├── CreateInvitationRequest.php
│ ├── ChangeMemberRoleRequest.php
│ └── ...
└── Resources/Api/V1/Team/
├── InvitationResource.php
├── TeamMemberResource.php
└── TeamStatsResource.php
The frontend mirrors this structure:
frontend/features/core/team/
├── api/ # useTeamApi(), useTeamRolesApi()
├── composables/ # useTeamMembers, useInvitations, useTeamActions, ...
├── components/ # TeamStats, TeamMemberCard, TeamInviteModal, ...
├── stores/ # useTeamStore (Pinia)
├── types.ts # TypeScript types
├── schemas.ts # Zod validation schemas
└── index.ts # Barrel exports
| Layer | Key Files | Purpose |
|---|---|---|
| Domain | TeamRole, InvitationStatus | Type-safe enums for roles and invitation states |
| Domain | InvitationToken | Secure token generation and hashing (SHA-256) |
| Domain | TeamContext | Safe Spatie permission scoping within tenant context |
| Domain | TeamException | Domain errors with typed factory methods |
| Application | InviteTeamMember, AcceptInvitation | Core invitation workflows |
| Application | CheckSeatQuota | Seat limit enforcement from subscription entitlements |
| Application | ChangeMemberRole, RemoveMember | Member management actions |
| Infrastructure | InvitationNotifier | Email delivery via queued mailables |
| HTTP | Controllers, Requests, Resources | API layer with validation and JSON output |
Key Events
Two domain events drive the integration between team membership and billing:
| Event | Dispatched When | Effect |
|---|---|---|
TeamMemberAdded | A user accepts an invitation and joins the tenant | Triggers SyncSeatQuantityJob to update Stripe subscription quantity |
TeamMemberRemoved | An admin removes a member from the tenant | Triggers SyncSeatQuantityJob to update Stripe subscription quantity |
Both events carry the Tenant and User references. The seat sync job runs asynchronously with a 30-second delay to batch rapid changes. See Seat-Based Billing Integration for the full pipeline.
Configuration
Team behavior is controlled via backend/config/team.php:
return [
// Days before a team invitation expires (default: 7)
'invitation_expires_days' => env('TEAM_INVITATION_EXPIRES_DAYS', 7),
// Seat behavior when tenant has no active subscription
// Options: 'owner_only' (1 seat), 'strict' (0 seats), 'unlimited'
'no_subscription_seat_mode' => env('TEAM_NO_SUBSCRIPTION_SEAT_MODE', 'owner_only'),
];
What's Next
- Invitations — The full invitation flow, token security, and API endpoints
- Role Management — Custom roles, permission matrix, and assignment rules
- Seat-Based Billing Integration — How team size connects to subscription billing
Testing Multi-Tenant Isolation
How to write tests that verify tenant data isolation: the WithTenantContext trait, test helpers, cross-tenant access patterns, strict mode testing, and common pitfalls.
Invitations
Team invitation flow: token-based security, invitation lifecycle, email notifications, public and protected API endpoints, and error handling.