Skip to main content

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:

LayerResponsibility
TenantServiceProviderRequest bootstrap; pick the host that identifies the tenant.
TenantResolverServiceRouting layer; short-circuits when the current tenant already matches.
TenantResolverUnifiedQueryServiceCache management, locks, unified queries, payload hydration.
CurrentTenantSingleton 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-Host is preferred. The custom-domain Caddy proxy fleet sits in front of tenant traffic and rewrites the upstream Host header, so the original tenant domain only survives in X-Forwarded-Host.
  • The standard Host header is the fallback when X-Forwarded-Host is absent (direct hits on the platform's own domain, local development, and console contexts).
  • The X-Farfalla-Tenant-Id header 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:

  1. The first worker acquires the lock, queries the database, and stores the result.
  2. Other workers wait (up to 3 seconds) for the lock.
  3. When the lock releases, waiting workers use the cached result.
  4. 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:

DataWhy Included
TenantCore tenant record
TenantMetaExtended settings and configuration
SalesSettingsPayment and commerce configuration
SignUpIntentPlan and billing information
FeaturesPre-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:

WhenWhat's Invalidated
Tenant saved/deletedID cache and all domain pointers
TenantMeta saved/deletedID cache (via tenant lookup)
SalesSettings saved/deletedID cache (via tenant lookup)
SignUpIntent saved/deletedID cache (via tenant lookup)
Plan/Tenant features changedID 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

ComponentDomain locationPurpose
TenantServiceProviderApplication providersBoots tenant resolution on every request; picks X-Forwarded-Host over Host, optionally honors X-Farfalla-Tenant-Id on the main app domain.
TenantResolverServiceShared domainRouting layer with short-circuit optimization; returns the current tenant immediately if the requested domain already matches.
TenantResolverUnifiedQueryServiceShared domainExecution engine; manages cache, locks, unified queries, and payload hydration.
CurrentTenantSaaS domainSingleton container bound by the 'CurrentTenant' string key; holds the resolved tenant and configures app URL, locale, cache prefix, and storage paths.
X

Graph View