Subdomain Routing
This page traces a single request — GET https://acme.autocom.app/api/v1/orders — from the browser all the way to the Postgres row, showing where the tenant is identified at each step.
The happy path (prod)
┌──────────────────────────────────────────────────────────────────────┐
│ 1. Browser acme.autocom.app → <ingress-LB-IP> │
│ (wildcard A record `*.autocom.app` in DNS) │
├──────────────────────────────────────────────────────────────────────┤
│ 2. Ingress / LB matches `host: "*.autocom.app"` rule │
│ terminates TLS (wildcard cert) │
│ forwards to nginx Service on port 80 │
├──────────────────────────────────────────────────────────────────────┤
│ 3. Nginx (per-ring) passes Host header untouched │
│ fastcgi_pass app:9000 │
├──────────────────────────────────────────────────────────────────────┤
│ 4. PHP-FPM / Octane Laravel boot, middleware pipeline runs │
├──────────────────────────────────────────────────────────────────────┤
│ 5. Tenancy middleware `InitializeTenancyByDomain` OR │
│ `InitializeTenancyByRequestData` resolves │
│ "acme" → Tenant row → switches DB connection │
├──────────────────────────────────────────────────────────────────────┤
│ 6. Route handler queries orders → hits tenant DB `tenantacme` │
│ ✓ returns data for the right tenant │
└──────────────────────────────────────────────────────────────────────┘
Step-by-step breakdown
Step 1 — DNS resolution
The production setup uses a single wildcard DNS record:
*.autocom.app IN A <cluster-ingress-LB-IP>
Any subdomain (acme, bob-widgets, whatever123) resolves to the same IP. No per-tenant DNS change is needed when a new tenant is provisioned. See K8s Wildcard Setup for how this is configured.
Step 2 — Ingress routing
The production ingress is a single resource with a wildcard host match:
apiVersion: networking.k8s.io/v1
kind: Ingress
spec:
rules:
- host: "*.autocom.app"
http:
paths:
- path: /
backend:
service:
name: nginx
port: { number: 80 }
tls:
- hosts: ["*.autocom.app"]
secretName: autocom-wildcard-tls
The ingress terminates TLS using a cert-manager-managed wildcard certificate and forwards the plain HTTP request to the per-ring nginx Service.
Step 3 — Nginx (per-ring service)
The per-ring nginx config is intentionally dumb — it just hands the request to PHP-FPM without touching the Host header:
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
# Host header is preserved by the default fastcgi_params include
}
The Host header at this point is acme.autocom.app.
Step 4 — Laravel tenancy middleware
Laravel has two ways to identify the tenant, both supported here:
-
InitializeTenancyByDomain— reads$request->getHost(), strips the configuredcentral_domainssuffix (e.g..autocom.app), queriesdomainstable for the remaining label. Used on the public tenant-facing routes. -
InitializeTenancyByRequestData— reads theX-Tenantheader. Used by API clients (mobile app, other internal services) that may not be hitting a subdomain.
Looking up acme in the domains table resolves to a Tenant row. Stancl then:
- Reconnects the default Eloquent connection to
tenantacme(the tenant-specific database) - Swaps the Redis prefix to
tenant_acme:for cache + session isolation - Adjusts the filesystem disk root to
storage/app/tenants/acme/
At this point, any query Laravel runs — Order::all(), User::find(1), Config::get(...) — is scoped to tenant acme without the caller knowing.
Step 5 — Route handler runs normally
The controller for /api/v1/orders is written as if there's only one tenant. Stancl makes that assumption true from inside the request by swapping connections at the boundary.
Step 6 — Response returns unmodified
The response goes back up the stack: nginx → ingress → browser. Nothing re-adds tenant context; it's not needed because the JSON payload is already tenant-scoped.
What about the 8-char random tenant ID?
Every tenant has two domain records by default:
| Domain type | Example | Purpose |
|---|---|---|
| Random ID | a3x8k2pq |
Primary, never changes, always unique, opaque |
| Human alias | acme-corp |
Secondary, user-visible, may change, may collide |
The random ID is stable forever. The human alias can be recycled or renamed without breaking anything — the random one is always available as a fallback. Both resolve to the same tenant via the domains table.
When provisioning tries to use an applicant's preferred_domain, it creates the human alias from that. See Preferred Subdomain.
The central domain
localhost and autocom.app (the bare domain, no subdomain) are central domains configured in backend/config/tenancy.php:
'central_domains' => [
'127.0.0.1',
'localhost',
'autocom.app', // production
],
Requests whose Host matches a central domain are not tenant-scoped — they hit the landlord / platform admin. This is how the landlord admin panel, install wizard, and marketing pages work.
Common pitfalls
- Missing
preserve_hostin ingress — some ingress controllers rewrite the Host header to the backend service name. Verify withcurl -H "Host: acme.autocom.app" http://<ingress>/upand check the logged Host. - Forgetting to re-initialize tenancy in queued jobs — Stancl re-initializes automatically via the
QueueTenancyBootstrapper, but custom jobs that bypass the dispatcher needtenancy()->initialize($tenant)explicitly. - Querying central tables from inside tenancy —
Tenant::find(...),User::...live on thecentralconnection, so use$model->setConnection('central')or the\App\Core\ModuleBus\TenantContextManager::runInCentral()helper.