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:

  1. InitializeTenancyByDomain — reads $request->getHost(), strips the configured central_domains suffix (e.g. .autocom.app), queries domains table for the remaining label. Used on the public tenant-facing routes.

  2. InitializeTenancyByRequestData — reads the X-Tenant header. 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_host in ingress — some ingress controllers rewrite the Host header to the backend service name. Verify with curl -H "Host: acme.autocom.app" http://<ingress>/up and 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 need tenancy()->initialize($tenant) explicitly.
  • Querying central tables from inside tenancyTenant::find(...), User::... live on the central connection, so use $model->setConnection('central') or the \App\Core\ModuleBus\TenantContextManager::runInCentral() helper.