Tenant Resolver & Caching
The tenant resolver decides which tenant a request belongs to and configures the application accordingly, with a cache-first design that keeps tenant lookups off the database on every request. This doc explains the resolution path, the two-tier cache, and the invariants that keep cache and database consistent.
Architecture Overview
Three responsibilities sit behind the resolver, plus a singleton that exposes the resolved tenant to the rest of the application:
| Layer | Responsibility |
|---|---|
TenantServiceProvider | Request bootstrap; pick the host that identifies the tenant. |
TenantResolverService | Routing layer; short-circuits when the current tenant already matches. |
TenantResolverUnifiedQueryService | Cache management, locks, unified queries, payload hydration. |
CurrentTenant | Singleton container; not an actor. Holds the resolved tenant for the request. |
Request Lifecycle
Resolution happens during Laravel's boot phase, before any controller runs. The service provider picks the host that identifies the tenant, the resolver fetches the payload (from cache when possible), and the application is configured against the resulting tenant.
Application code reads the resolved tenant through the tenant() helper, which returns the instance held by the CurrentTenant singleton.
Host extraction
The service provider reads two different request signals:
X-Forwarded-Hostis preferred. The custom-domain Caddy proxy fleet sits in front of tenant traffic and rewrites the upstreamHostheader, so the original tenant domain only survives inX-Forwarded-Host.- The standard
Hostheader is the fallback whenX-Forwarded-Hostis absent (direct hits on the platform's own domain, local development, and console contexts). - The
X-Farfalla-Tenant-Idheader is a separate, ID-based resolution path. It is only honored when the request lands on the platform's main app domain; on any tenant domain the header is ignored. This is the mechanism used by internal tooling that needs to target a tenant by ID without owning a domain.
Caching Strategy
Two-Tier Caching
The cache uses a domain pointer pattern:
domain:example.com → "42" (pointer to ID)
id:42 → {full payload} (actual data)
Why this design
- IDs are stable, domains change. When a tenant updates its final domain, only the pointer needs updating; the payload entry under the ID does not move.
- Many domains can point to one tenant. Custom domains, subdomains, and aliases all resolve to the same payload.
- Single source of truth. The ID-based cache is authoritative, simplifying invalidation logic.
TTL Randomization
Cache entries expire with a randomized TTL between 700 and 1000 seconds.
Why randomize After a deploy or cache flush, every worker would otherwise hit the database simultaneously when the cache expired. Randomized TTL spreads the refresh across time, preventing load spikes.
Lock Mechanism
When the cache misses, a lock prevents multiple workers from executing the same query for the same tenant:
- The first worker acquires the lock, queries the database, and stores the result.
- Other workers wait (up to 3 seconds) for the lock.
- When the lock releases, waiting workers use the cached result.
- If the lock times out, the worker queries independently as a graceful fallback.
Why locks Without them, a cache miss on a popular tenant could trigger hundreds of identical queries from concurrent workers.
Unified Payload
The cache stores a complete tenant payload in a single entry:
| Data | Why Included |
|---|---|
Tenant | Core tenant record |
TenantMeta | Extended settings and configuration |
SalesSettings | Payment and commerce configuration |
SignUpIntent | Plan and billing information |
Features | Pre-computed feature flags and limits |
Why unified
- Eliminates N+1 queries. A single JOIN query fetches everything instead of separate queries per relation.
- Single cache entry. One cache hit returns all tenant data needed for request handling.
- Features pre-computed. The Features System values are computed at cache time, making feature checks instant.
Cache Invalidation
Invalidation happens automatically via Laravel model observers:
| When | What's Invalidated |
|---|---|
| Tenant saved/deleted | ID cache and all domain pointers |
| TenantMeta saved/deleted | ID cache (via tenant lookup) |
| SalesSettings saved/deleted | ID cache (via tenant lookup) |
| SignUpIntent saved/deleted | ID cache (via tenant lookup) |
| Plan/Tenant features changed | ID cache (via tenant lookup) |
Domain pointer healing: A domain pointer can outlive the payload it points to (for example, if the ID cache was flushed independently or expired earlier). When the next request hits a pointer whose ID-keyed payload is missing, the resolver forgets the stale pointer, re-runs the unified query under a lock, and repopulates both the ID payload and any domain pointers found in the result.
Manual invalidation: Flush via the unified query service to force a cache refresh.
Components Reference
| Component | Domain location | Purpose |
|---|---|---|
TenantServiceProvider | Application providers | Boots tenant resolution on every request; picks X-Forwarded-Host over Host, optionally honors X-Farfalla-Tenant-Id on the main app domain. |
TenantResolverService | Shared domain | Routing layer with short-circuit optimization; returns the current tenant immediately if the requested domain already matches. |
TenantResolverUnifiedQueryService | Shared domain | Execution engine; manages cache, locks, unified queries, and payload hydration. |
CurrentTenant | SaaS domain | Singleton container bound by the 'CurrentTenant' string key; holds the resolved tenant and configures app URL, locale, cache prefix, and storage paths. |