Motivation
Multi-tenant deployment gives the back end a per-requesttenant_id that scopes every Eloquent query (R30/R31). But a user who
belongs to more than one team needs a front-end that makes the active team
explicit, switches between teams without leaking data, and proves to the server
that the team they ask for is one they actually belong to.
The team switcher is that front-end half. It turns the abstract
X-Tenant-Id header into a first-class part of the URL and the UI, so the active
tenant is always visible, bookmarkable, and impossible to confuse across a switch.
Design
The piecesReserved fallbacks never become teams
The legacy literaldefault may still be used internally when no request
tenant has been resolved, but it is reserved and non-operational.
UserTeamsResolver filters it even when stale membership data exists, and
AuthorizeTenantHeader rejects it before any tenant-aware query. Real teams
(for example acme) always send X-Tenant-Id and remain membership-scoped.
Cache isolation on switch
Switching team must never render one tenant’s cached data under another.switchTeam
therefore cancelQueries() + clear()s the entire TanStack Query cache, and
AppShell keys the route outlet on currentTeam so all page-local state remounts.
A persisted selection is honoured only if it still belongs to the same user and
still exists in the fresh /api/auth/me teams list — otherwise it falls back to
the first team, so a revoked membership self-heals on the next bootstrap.
Authorization — membership is mandatory
AuthorizeTenantHeader runs after auth:sanctum and before any
tenant-aware query. It requires a project_membership in the resolved tenant,
whether that tenant came from X-Tenant-Id or the header-less default
fallback. Reserved fallbacks are rejected regardless of membership. A
membership in tenant B never opens tenant A and another user’s
membership never helps. Anything else returns 403 tenant_forbidden,
which the front-end response interceptor turns into a snap-back to the first valid
team.
Worked example
A user who is a member ofacme and globex logs in:
GET /api/auth/mereturnsteams: [{tenant_id:'acme',hash:…}, {tenant_id:'globex',hash:…}].- The SPA boots at the persisted membership or
/app/{acmeHash}/…. - The user picks acme in the topbar. The cache is cleared, the outlet remounts, the URL becomes
/app/{acmeHash}/admin/dashboard, and every call now carriesX-Tenant-Id: acme. - A forged
X-Tenant-Id: stark(no membership) →403 tenant_forbidden→ the SPA resets to the first valid team and reloads.
Gotchas
teamHashis a routing namespace, not a secret. Authorization always stays on the server-validatedX-Tenant-Id; guessing or forging a hash discloses and grants nothing.- Don’t synthesize or expose
default. It is a compatibility storage fallback, not a company; no membership may put it in/api/auth/me.teams. - Zero teams is valid store state, but it is gated. The store keeps
currentTeam = null; System Admins land on the global tenant registry, while normal accounts are routed to resumable company onboarding and cannot enter a tenant dashboard until they create a company. The no-tenant page remains the compatibility fallback only when the backend explicitly reports onboarding as unavailable. - Live stale-tenant recovery is best-effort. The interceptor auto-recovers only on the host’s
tenant_forbidden403; package routes reject with their own statuses (404/410/423) and self-heal on the next/api/auth/mebootstrap.