Invitations
Team members join a tenant through an invitation system. Invitations are token-based, email-verified, and support both existing users (who accept while logged in) and new users (who register and accept in a single step). The system enforces seat quotas, prevents duplicates, and protects against race conditions.
Invitation Flow
The complete invitation process follows these steps:
- An admin creates an invitation via
POST /api/v1/tenant/{tenantId}/team/invitationswith the recipient's email and desired role. - The system validates the request — checks the inviter's
team.invitepermission, verifies no duplicate invitation or existing membership, and confirms the seat quota allows a new member. A cache lock prevents race conditions from concurrent requests. - A token is generated — a 64-character hex string. The SHA-256 hash is stored in the database; the plain token is never persisted.
- An email notification is queued containing an accept URL with the plain token. The email is localized to the invitation's locale.
- The recipient clicks the link — the frontend loads invitation details via
GET /api/v1/invitations/{token}. - Acceptance happens through one of two paths:
- Existing user (logged in):
POST /api/v1/invitations/{token}/accept— attaches the user to the tenant and assigns the role. - New user (not registered):
POST /api/v1/invitations/{token}/accept-with-registration— creates the user account, attaches them to the tenant, assigns the role, and returns authentication tokens.
- Existing user (logged in):
- The
TeamMemberAddedevent is dispatched, triggering seat quantity sync with Stripe if the tenant has a seat-based plan.
Token Security
Invitation tokens are managed through the InvitationToken value object, which enforces secure generation, storage, and verification:
final readonly class InvitationToken implements Stringable
{
private const TOKEN_LENGTH = 64;
public static function generate(): self
{
$plain = bin2hex(random_bytes(self::TOKEN_LENGTH / 2));
return new self($plain, hash('sha256', $plain));
}
public static function fromPlain(string $plainToken): self
{
if (strlen($plainToken) !== self::TOKEN_LENGTH || ! ctype_xdigit($plainToken)) {
throw new InvalidArgumentException('Invalid token format');
}
return new self($plainToken, hash('sha256', $plainToken));
}
public function matches(string $plainToken): bool
{
return hash_equals($this->hashedToken, hash('sha256', $plainToken));
}
}
Key security properties:
- 64-character hex tokens (32 random bytes) provide 256 bits of entropy
- SHA-256 hashing before database storage — a database compromise does not reveal usable tokens
- Timing-safe comparison via
hash_equals()prevents timing attacks - Plain tokens exist only in email links and in-memory during generation — they are never stored
Invitation Lifecycle
Invitations move through a defined set of states:
enum InvitationStatus: string
{
case PENDING = 'pending';
case ACCEPTED = 'accepted';
case REVOKED = 'revoked';
case EXPIRED = 'expired';
public function isPending(): bool
{
return $this === self::PENDING;
}
public function isTerminal(): bool
{
return in_array($this, [self::ACCEPTED, self::REVOKED, self::EXPIRED], true);
}
}
State Transitions
| From | To | Trigger |
|---|---|---|
| — | pending | Invitation created |
pending | accepted | User accepts the invitation |
pending | revoked | Admin revokes the invitation |
pending | expired | Expiry date reached (checked at acceptance time) |
expired | pending | Admin resends the invitation (new token, reset expiry) |
Once an invitation reaches accepted or revoked, it cannot transition to any other state. The expired state is the only terminal state that can be reversed — via the resend endpoint, which generates a new token and resets the expiry.
Expiration and Resend
Invitation expiry is configurable via environment variable:
return [
'invitation_expires_days' => env('TEAM_INVITATION_EXPIRES_DAYS', 7),
// ...
];
Expiration is checked at acceptance time, not by a background job. An invitation with expires_at in the past is treated as expired when someone tries to accept it.
Resending an Invitation
The resend operation (POST /api/v1/tenant/{tenantId}/team/invitations/{id}/resend):
- Generates a new token (the old token becomes invalid)
- Resets the
expires_atto a new window from the current time - Sets the status back to
pending(if it wasexpired) - Queues a new email with the new accept URL
- Optionally updates the invitation's locale
Resend is available for invitations in pending or expired status. Accepted and revoked invitations cannot be resent.
Security Validations
The system performs comprehensive validation at both invitation creation and acceptance:
At Creation Time
| Validation | Exception | Code |
|---|---|---|
Inviter has team.invite permission | insufficientPermissions | INSUFFICIENT_PERMISSIONS |
| Email is not already a team member | alreadyMember | ALREADY_MEMBER |
| User is not in another tenant (V1 constraint) | userBelongsToAnotherTenant | USER_BELONGS_TO_ANOTHER_TENANT |
| No valid pending invitation for this email | alreadyInvited | ALREADY_INVITED |
| Seat quota allows a new member | seatLimitReached | SEAT_LIMIT_REACHED |
At Acceptance Time
| Validation | Exception | Code |
|---|---|---|
| Token matches a valid invitation | invitationNotFound | INVITATION_NOT_FOUND |
| Invitation is not already accepted | invitationAlreadyAccepted | INVITATION_ALREADY_ACCEPTED |
| Invitation is not revoked | invitationAlreadyRevoked | INVITATION_REVOKED |
| Invitation is not expired | invitationExpired | INVITATION_EXPIRED |
| User's email matches invitation email | emailMismatch | EMAIL_MISMATCH |
| No existing account (registration path) | userAlreadyExists | ACCOUNT_ALREADY_EXISTS |
Race Condition Protection
Invitation creation uses a cache lock to prevent duplicate invitations from concurrent requests:
$lock = Cache::lock("invite:{$data->tenantId}:{$data->email}", 5);
if (! $lock->get()) {
throw TeamException::generic(__('team.exceptions.concurrent_invitation'));
}
try {
// Validation + creation inside DB transaction
} finally {
$lock->release();
}
The lock key is composed of the tenant ID and email address, with a 5-second timeout. This ensures that even if two admins click "invite" simultaneously for the same email, only one invitation is created.
API Endpoints
Public Endpoints (No Authentication)
These endpoints are accessible without authentication, using only the invitation token. They are rate-limited to 30 requests per minute.
| Method | Path | Description |
|---|---|---|
GET | /api/v1/invitations/{token} | View invitation details (tenant name, inviter, role, status) |
POST | /api/v1/invitations/{token}/accept-with-registration | Create a new user account and accept the invitation |
GET response:
{
"data": {
"id": "9e2f3a4b-5c6d-7e8f-9a0b-1c2d3e4f5a6b",
"email": "jane@example.com",
"role": "admin",
"status": "pending",
"expires_at": "2026-04-02T12:00:00.000000Z",
"is_expired": false,
"is_valid": true,
"tenant": {
"id": "8a1b2c3d-4e5f-6a7b-8c9d-0e1f2a3b4c5d",
"name": "Acme Corp"
},
"inviter": {
"id": 1,
"name": "John Doe"
},
"created_at": "2026-03-26T12:00:00.000000Z",
"updated_at": "2026-03-26T12:00:00.000000Z"
}
}
Accept with registration request:
{
"name": "Jane Smith",
"email": "jane@example.com",
"password": "securepassword",
"password_confirmation": "securepassword"
}
The response includes the newly created user, the invitation, the tenant, and authentication tokens (access + refresh).
Authenticated Endpoint (No Tenant Context)
| Method | Path | Description |
|---|---|---|
POST | /api/v1/invitations/{token}/accept | Accept invitation as the currently logged-in user |
The authenticated user's email must match the invitation email. The response includes the invitation and tenant data.
Protected Endpoints (Auth + Tenant Context)
These endpoints require authentication (auth:sanctum) and tenant context (tenant.resolve, tenant.member, onboarding.complete).
| Method | Path | Description |
|---|---|---|
GET | /api/v1/tenant/{tenantId}/team/invitations | List invitations (optionally filter with ?pending_only=true) |
POST | /api/v1/tenant/{tenantId}/team/invitations | Create a new invitation |
DELETE | /api/v1/tenant/{tenantId}/team/invitations/{id} | Revoke a pending invitation |
POST | /api/v1/tenant/{tenantId}/team/invitations/{id}/resend | Resend with a new token |
Create invitation request:
{
"email": "new-member@example.com",
"role": "member",
"expires_in_days": 14
}
The role field accepts any valid web-guard role name (built-in or custom) except owner. The expires_in_days field is optional (defaults to 7, max 30).
Email Notification
When an invitation is created or resent, the system queues a localized email via TeamInvitationMail:
- The email is sent through Laravel's queue system (
ShouldQueue) for reliability - The template includes the tenant name, inviter name, assigned role, accept URL, and expiry date
- The accept URL format is:
{FRONTEND_URL}/{locale}/invitation/accept?token={plainToken} - Email locale is set per invitation (defaults to
en, supportsfr,es,it) - The email is sent outside the database transaction — if the invitation record is created but the queue dispatch fails, the invitation still exists and can be resent
The notification contract (InvitationNotifierInterface) is bound to InvitationNotifier in the TeamServiceProvider, keeping the domain layer independent of mail infrastructure.
Error Codes
All team-related errors follow the standard API error format with a code field for programmatic handling:
| Code | HTTP Status | Description |
|---|---|---|
INVITATION_NOT_FOUND | 404 | Token does not match any invitation |
INVITATION_EXPIRED | 410 | Invitation past its expiry date |
INVITATION_ALREADY_ACCEPTED | 410 | Invitation has already been used |
INVITATION_REVOKED | 410 | Invitation was revoked by an admin |
EMAIL_MISMATCH | 403 | Authenticated user's email does not match invitation |
INSUFFICIENT_PERMISSIONS | 403 | User lacks the team.invite permission |
ACCOUNT_ALREADY_EXISTS | 409 | Registration path but email already has an account |
ALREADY_MEMBER | 409 | Invitee is already a tenant member |
ALREADY_INVITED | 409 | A valid pending invitation already exists for this email |
SEAT_LIMIT_REACHED | 422 | Tenant's seat quota is full |
USER_BELONGS_TO_ANOTHER_TENANT | 409 | User already belongs to a different tenant (V1 constraint) |
INVALID_TOKEN_FORMAT | 400 | Token is not a valid 64-character hex string |
Frontend Integration
The frontend provides a complete invitation management experience through dedicated composables and components:
Composables
useInvitations()— Facade over the team store providing enriched invitations withisRevocableandisResendableflags, plus permission checksuseTeamActions()— Mutation composable forinvite(),revokeInvitation(), andresendInvitation()with error code extractionuseInvitationAccept()— Standalone composable for the public accept page, managing a state machine (loading→invalid/accept-button/registration-form/wrong-account)
Components
TeamInvitationForm— Email + role form with client-side Zod validation and error code → i18n key mappingTeamInvitationList— Displays invitations with status badges, action buttons (resend/revoke), and confirmation modalsTeamInviteModal— Modal wrapper around the form with an optional billing notice for seat-based plans
Accept Page State Machine
The useInvitationAccept composable manages the public invitation accept flow through a state machine:
| State | Condition | UI |
|---|---|---|
loading | Invitation loading or auth not yet initialized | Loading spinner |
invalid | Invitation not found, expired, revoked, or accepted | Error message with specific reason |
accept-button | Authenticated user with matching email | "Accept Invitation" button |
wrong-account | Authenticated but email doesn't match | Warning with logout prompt |
registration-form | Not authenticated | Registration form |
V1 Constraints
These constraints simplify permission management and tenant isolation for the initial release. The many-to-many data model (tenant_user pivot) is already in place for future multi-tenant support.
Teams Overview
Team architecture in SaaS4Builders: tenant membership, built-in roles, Spatie Permissions integration, TeamContext scoping, and domain structure.
Role Management
Built-in roles and permissions, custom tenant-scoped roles, role assignment rules, and the Spatie Permissions integration for team access control.