Skip to main content

Motivation

Multi-tenant deployment gives the back end a per-request tenant_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 pieces

Reserved fallbacks never become teams

The legacy literal default 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 of acme and globex logs in:
  1. GET /api/auth/me returns teams: [{tenant_id:'acme',hash:…}, {tenant_id:'globex',hash:…}].
  2. The SPA boots at the persisted membership or /app/{acmeHash}/….
  3. 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 carries X-Tenant-Id: acme.
  4. A forged X-Tenant-Id: stark (no membership) → 403 tenant_forbidden → the SPA resets to the first valid team and reloads.

Gotchas

  • teamHash is a routing namespace, not a secret. Authorization always stays on the server-validated X-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_forbidden 403; package routes reject with their own statuses (404/410/423) and self-heal on the next /api/auth/me bootstrap.
See also: Multi-tenant isolation, The project registry.