Zodiac Tech CRM Docs

Multi-tenancy & isolation

How one install serves many businesses safely — the fail-closed BelongsToTenant scope, current_tenant binding, the SA HQ workspace, and the rules that keep data apart.

developer Advanced ⏱ 12 min Updated 2026-10-05

Multi-tenancy & isolation

This product is single-database multi-tenant: every business ("workspace"; the tenants table) shares the schema, and a fail-closed global scope keeps their rows apart. The design rule is simple: if the code cannot prove which tenant a request belongs to, queries return nothing — they never fall back to "all tenants".

Who this is for

Developers adding models, queries, endpoints or panel pages — and anyone debugging "why is this list empty?" or a data-visible-only-to-the-wrong-team bug.

Difficulty: Advanced · Time: about 12 minutes.

The pieces

Piece Role
tenants table One row per business (plus the internal HQ row). UI calls it "Business"
App\Models\Concerns\BelongsToTenant Global scope + auto-stamp for tenant-owned models
app('current_tenant') The per-request binding the scope reads
App\Http\Middleware\ResolveTenant Web: resolves and binds the workspace (skips /super-admin)
App\Http\Middleware\EnforceTenantScope (alias tenant.scope) Logs out a non-SA user who has no workspace
App\Http\Middleware\BindHqTenant Super Admin panel: binds the internal "LeadHub HQ" workspace
->withoutGlobalScope('tenant') The explicit opt-in for cross-tenant SA queries

How a request gets its tenant

flowchart TD
    A[Request] --> B{Which surface?}
    B -->|Web panel| C[ResolveTenant binds current_tenant]
    B -->|REST /api/v1| D[ApiKeyAuthentication binds the key's tenant]
    B -->|Mobile| E[DeviceAuthenticate binds the device's tenant]
    B -->|/super-admin| F[BindHqTenant binds LeadHub HQ]
    C --> G[BelongsToTenant reads current_tenant]
    D --> G
    E --> G
    F --> G
    G --> H{Resolved?}
    H -->|Yes| I[WHERE tenant_id = current]
    H -->|No, not SA| J[WHERE 0 = 1 — empty, fail closed]
    H -->|No, SA page| K[Unfiltered cross-tenant read]

The scope itself

BelongsToTenant::bootBelongsToTenant() does three things:

  1. Filter: adds global scope tenant → where(table.tenant_id, resolvedTenantId).
  2. Fail closed: when no tenant resolves and the user is not a Super Admin → whereRaw('0 = 1') (empty result, no leak).
  3. Auto-stamp: on creating, fills tenant_id from the resolved context when the caller didn't set it.

Resolution order: app('current_tenant') first, then auth()->user()->tenant_id. A Super Admin context without a bound tenant is intentionally unfiltered (that is what powers cross-tenant SA tables) — SA code that only wants one workspace must bind it or filter explicitly.

The "LeadHub HQ" special case

ResolveTenant deliberately skips /super-admin, so tenant-scoped queries there would otherwise run unfiltered — fine for cross-tenant admin lists, wrong for the SA panel's own CRM-shaped pages. BindHqTenant (registered as persistent panel middleware, so Livewire /livewire/update requests keep the binding) solves that:

  • Only for super-admin users; a no-op everywhere else.
  • Finds the internal workspace via the settings.internal.crm_hq marker (created by php artisan leadhub:setup-hq-crm).
  • Caches the id under leadhub.hq_tenant_id for 60 seconds; caches 0 for "not provisioned yet"; never throws (an unmigrated install keeps the legacy behavior).

SA pages that need true cross-tenant visibility call ->withoutGlobalScope('tenant') explicitly — grep that string to audit them.

Concrete isolation example

Two businesses both receive a Meta lead whose form submission has source_id = 4711:

  • leads has unique(tenant_id, source, source_id) — both rows can exist, one per tenant, with no collision.
  • Tenant A's agent lists leads: the scope injects WHERE leads.tenant_id = A, so B's lead is invisible even if the agent guesses its id (a find() for B's id returns 404 through the scope).
  • A background job that forgot to bind any tenant would insert nothing readable and read nothing: tenant_id auto-stamp resolves to null, and reads hit WHERE 0 = 1. Fail-closed, loudly visible in development, safe in production.

Rules for new code

  1. Any tenant-owned table: add tenant_id, use BelongsToTenant on the model, composite unique (tenant_id + natural key).
  2. Never trust a tenant_id from the request body; bind the context (middleware / job constructor) and let auto-stamp fill it.
  3. In jobs and commands, bind explicitly before touching scoped models:
    app()->instance('current_tenant', $tenant);
    
    (the billing/onboarding jobs in app/Jobs follow this pattern).
  4. In SA pages, opt out only deliberately with ->withoutGlobalScope('tenant'), and never on a query whose results a tenant user can reach.
  5. When writing tests, remember the scope: bind a tenant or assert the empty-result behavior intentionally — see Testing the platform.

Was this page helpful?