Seat-Based Billing Integration
The team system integrates directly with billing to enforce seat limits and automatically sync member counts with Stripe. When a tenant subscribes to a seat-based plan, the team_members feature entitlement defines how many users can join the team. Adding or removing members triggers an asynchronous pipeline that updates the Stripe subscription quantity.
How Seats Connect to Billing
Seat limits are determined by the tenant's active subscription plan. Plans define features with entitlements that control what tenants can do. The team_members feature code is the key that links billing to team management.
The chain works as follows:
- A Plan has Entitlements linking to Features
- One of those features has
code = 'team_members' - The entitlement's type determines whether seats are limited or unlimited
- The
CheckSeatQuotaaction reads this entitlement to enforce limits
For details on how plans, features, and entitlements are structured, see Pricing Models.
Seat Quota Check
The CheckSeatQuota action is the central point for all seat-related decisions. It provides three methods:
final class CheckSeatQuota
{
/**
* Check if the tenant can add more members.
*/
public function canAddMembers(Tenant $tenant, int $additionalMembers = 1): bool
{
$limit = $this->getSeatLimit($tenant);
if ($limit === null) {
return true;
}
$usage = $this->getCurrentUsage($tenant);
$totalAfterAdd = $usage['members'] + $usage['pending_invitations'] + $additionalMembers;
return $totalAfterAdd <= $limit;
}
/**
* Get the seat limit for a tenant.
*
* @return int|null The seat limit, or null for unlimited
*/
public function getSeatLimit(Tenant $tenant): ?int
{
$subscription = $this->getSubscription->execute($tenant->id);
if ($subscription === null) {
return $this->getNoSubscriptionLimit();
}
$plan = $subscription->plan;
$plan->load('entitlements.feature');
foreach ($plan->entitlements as $entitlement) {
if ($entitlement->feature->code === BillingReadinessChecker::SEAT_FEATURE_CODE) {
return match ($entitlement->type) {
EntitlementType::Boolean => null,
EntitlementType::Quota => $entitlement->value,
};
}
}
return null;
}
/**
* Get usage statistics for seat quota.
*
* @return array{members: int, pending_invitations: int, total: int, limit: int|null, available: int|null}
*/
public function getUsageStats(Tenant $tenant, ?int $precomputedLimit = null): array
{
$usage = $this->getCurrentUsage($tenant);
$limit = $precomputedLimit ?? $this->getSeatLimit($tenant);
$total = $usage['members'] + $usage['pending_invitations'];
$available = $limit !== null ? max(0, $limit - $total) : null;
return [
'members' => $usage['members'],
'pending_invitations' => $usage['pending_invitations'],
'total' => $total,
'limit' => $limit,
'available' => $available,
];
}
}
The usage count includes both current members and pending invitations. This prevents over-inviting — if a plan allows 5 seats, you can have 3 members and 2 pending invitations, but not 3 members and 3 pending invitations.
Entitlement Types
How the seat limit is derived depends on the entitlement type:
| Entitlement Type | Seat Behavior | Example |
|---|---|---|
Boolean | Unlimited seats — the feature is enabled with no cap | Enterprise plan: "team_members: true" |
Quota | Numeric limit — the entitlement value is the max seat count | Pro plan: "team_members: 10" |
| No entitlement | Unlimited — if the plan has no team_members entitlement, seats are uncapped | Legacy plan without team feature |
The BillingReadinessChecker::SEAT_FEATURE_CODE constant ('team_members') is the hardcoded feature code used to look up the seat entitlement. This code must match the feature's code field in your plan configuration.
No-Subscription Modes
When a tenant has no active subscription (e.g., during a free trial period or after cancellation), seat behavior is controlled by a configuration value:
return [
// ...
'no_subscription_seat_mode' => env('TEAM_NO_SUBSCRIPTION_SEAT_MODE', 'owner_only'),
];
| Mode | Limit | Behavior |
|---|---|---|
owner_only | 1 seat | Default. Only the owner exists — no invitations possible until a subscription is active. |
strict | 0 seats | No seats at all. Even the most restrictive option. |
unlimited | No limit | No seat restrictions. Use this if you want a free tier with full team support. |
The resolution logic:
private function getNoSubscriptionLimit(): ?int
{
$mode = config('team.no_subscription_seat_mode', 'owner_only');
return match ($mode) {
'strict' => 0,
'owner_only' => 1,
'unlimited' => null,
default => 1,
};
}
TEAM_NO_SUBSCRIPTION_SEAT_MODE environment variable to unlimited if you want to offer a free tier where users can invite team members without subscribing.Enforcement Point
Seat quotas are checked at invitation creation time, not at acceptance time. This design choice has important implications:
- Pending invitations count toward the quota —
total = members + pending_invitations - If seats are reduced after invitations are sent, existing pending invitations can still be accepted
- Existing members are never removed when a plan's seat count is reduced
- New invitations are blocked when the quota is full
The InviteTeamMember action calls CheckSeatQuota::canAddMembers() before creating the invitation. If the check fails, a SEAT_LIMIT_REACHED error is returned.
Automatic Seat Syncing
When team membership changes, the system automatically updates the Stripe subscription quantity through an event-driven pipeline:
Pipeline
TeamMemberAddedorTeamMemberRemovedevent — dispatched synchronously when a member joins or is removedSyncSeatQuantityOnMemberChangelistener — catches the event and dispatches a delayed jobSyncSeatQuantityJob— runs asynchronously after a 30-second delaySyncSeatQuantityaction — updates the Stripe subscription quantity and dispatchesSeatQuantityChanged
Listener
The listener dispatches the job with a 30-second delay to allow batch operations (e.g., inviting multiple members) to settle before making a Stripe API call:
final class SyncSeatQuantityOnMemberChange
{
private const int DELAY_SECONDS = 30;
public function handleMemberAdded(TeamMemberAdded $event): void
{
SyncSeatQuantityJob::dispatch($event->tenant)
->delay(now()->addSeconds(self::DELAY_SECONDS));
}
public function handleMemberRemoved(TeamMemberRemoved $event): void
{
SyncSeatQuantityJob::dispatch($event->tenant)
->delay(now()->addSeconds(self::DELAY_SECONDS));
}
public function subscribe(Dispatcher $events): array
{
return [
TeamMemberAdded::class => 'handleMemberAdded',
TeamMemberRemoved::class => 'handleMemberRemoved',
];
}
}
Job Deduplication
The SyncSeatQuantityJob uses Laravel's ShouldBeUnique to prevent duplicate Stripe API calls for the same tenant:
final class SyncSeatQuantityJob implements ShouldBeUnique, ShouldQueue
{
public int $tries = 3;
public function __construct(
public readonly Tenant $tenant,
) {}
public function uniqueId(): string
{
return $this->tenant->id;
}
public function uniqueFor(): int
{
return 60; // 60-second uniqueness window
}
public function backoff(): array
{
return [10, 30, 60]; // Progressive retry backoff
}
public function handle(SyncSeatQuantity $action): void
{
$action->execute($this->tenant);
}
}
ShouldBeUnique interface with a 60-second uniqueness window means that if multiple members are added in rapid succession, only one Stripe API call is made. The job deduplicates by tenant ID, so concurrent dispatches for the same tenant collapse into a single execution.Sync Action
The SyncSeatQuantity action performs the actual Stripe update:
- Loads the tenant's active subscription
- Checks that the plan uses seat-based pricing (
PricingType::Seat) - Counts current members (minimum 1 — the owner always counts)
- Skips the API call if the count hasn't changed
- Updates the Stripe subscription quantity via the payment gateway
- Updates the local subscription record
- Dispatches
SeatQuantityChangedevent with old and new quantities
The proration behavior is configurable per plan via the seat_proration_behavior field (defaults to create_prorations).
TeamStats API Response
The GET /api/v1/tenant/{tenantId}/team/stats endpoint returns seat quota information alongside member counts:
{
"data": {
"members": 3,
"pending_invitations": 2,
"total": 5,
"limit": 10,
"available": 5
}
}
| Field | Type | Description |
|---|---|---|
members | int | Current number of team members |
pending_invitations | int | Valid pending invitations |
total | int | members + pending_invitations |
limit | int or null | Seat limit from the plan (null = unlimited) |
available | int or null | Remaining seats: limit - total (null = unlimited) |
This response is produced by the TeamStatsResource, which delegates to CheckSeatQuota::getUsageStats().
Frontend Awareness
The frontend uses the stats response to provide real-time feedback about seat availability.
Store Logic
The Pinia team store computes canAddMembers from the stats:
const canAddMembers = computed(() => {
if (!stats.value) return false
const limit = stats.value.limit
if (limit === null) return true
if (limit === 0) return false
return (stats.value.available ?? 0) > 0
})
This computed property drives the "Invite Member" button state — it's disabled when no seats are available.
TeamStats Component
The TeamStats component (frontend/features/core/team/components/TeamStats.vue) displays a three-card overview:
- Members count — current team size
- Pending invitations — valid pending invitations
- Seats —
{used} of {limit}or "Unlimited" when limit is null (displayed as ∞)
Billing Notice in Invite Modal
When the tenant is on a seat-based plan, the TeamInviteModal component shows an informational alert before the invitation form:
<UAlert
v-if="isSeatBasedPlan"
color="info"
variant="subtle"
icon="i-lucide-info"
:title="t('billing.seats.addMemberBillingNotice')"
class="mb-4"
/>
This informs the user that adding a member may affect their billing — particularly important for seat-based plans where each member incurs a per-seat charge.
Seat Billing Composable
The useSeatBilling() composable (in frontend/features/core/docs/billing/composables/useSeatBilling.ts) provides detailed billing-side seat information:
isSeatBased— whether the current plan uses seat pricingcurrentSeats/seatLimit/availableSeats— numeric seat valuesisAtLimit/isNearLimit— computed flags for UI warningsformattedPricePerSeat/formattedTotalCost— formatted money values for display
This composable bridges the billing and team domains, giving components access to pricing information without crossing feature boundaries.
Cross-References
- Pricing Models — How seat-based plans are configured with features and entitlements
- Subscriptions & Lifecycle — Subscription states that affect seat quota resolution
- Teams Overview — General team architecture and role system
- Invitations — How seat checks integrate with the invitation creation flow
Role Management
Built-in roles and permissions, custom tenant-scoped roles, role assignment rules, and the Spatie Permissions integration for team access control.
Settings Overview
Hierarchical settings system with three-level cascade (Application, Tenant, User), centralized key registry, scope-aware resolution, and full-stack API support.