Skip to content
SaaS4Builders
Teams

Teams Overview

Team architecture in SaaS4Builders: tenant membership, built-in roles, Spatie Permissions integration, TeamContext scoping, and domain structure.

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();
}

Every tenant also has an owner — the user who created it during onboarding:

backend/app/Models/Tenant.php
public function owner(): BelongsTo
{
    return $this->belongsTo(User::class, 'owner_id');
}
In V1, a user can only belong to one tenant at a time. This constraint is enforced at both invitation creation and acceptance. If you try to invite a user who already belongs to another tenant, the system returns a USER_BELONGS_TO_ANOTHER_TENANT error.

Built-in Roles

The system seeds three built-in roles that define the default permission hierarchy:

backend/app/Domain/Team/Enums/TeamRole.php
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:

PermissionDescriptionOwnerAdminMember
tenant.updateUpdate tenant name, settings
tenant.deleteDelete the tenant
team.inviteSend team invitations
team.removeRemove team members
team.manageGeneral team management
team.transfer_ownershipTransfer ownership to another member
billing.viewView subscription and invoices
billing.manageManage subscriptions and payment methods
settings.viewView tenant settings
roles.manageCreate, 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:

backend/app/Domain/Team/Support/TeamContext.php
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');
});
Always use 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
LayerKey FilesPurpose
DomainTeamRole, InvitationStatusType-safe enums for roles and invitation states
DomainInvitationTokenSecure token generation and hashing (SHA-256)
DomainTeamContextSafe Spatie permission scoping within tenant context
DomainTeamExceptionDomain errors with typed factory methods
ApplicationInviteTeamMember, AcceptInvitationCore invitation workflows
ApplicationCheckSeatQuotaSeat limit enforcement from subscription entitlements
ApplicationChangeMemberRole, RemoveMemberMember management actions
InfrastructureInvitationNotifierEmail delivery via queued mailables
HTTPControllers, Requests, ResourcesAPI layer with validation and JSON output

Key Events

Two domain events drive the integration between team membership and billing:

EventDispatched WhenEffect
TeamMemberAddedA user accepts an invitation and joins the tenantTriggers SyncSeatQuantityJob to update Stripe subscription quantity
TeamMemberRemovedAn admin removes a member from the tenantTriggers 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:

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