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.
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:
- Filter: adds global scope
tenant→where(table.tenant_id, resolvedTenantId). - Fail closed: when no tenant resolves and the user is not a Super Admin →
whereRaw('0 = 1')(empty result, no leak). - Auto-stamp: on
creating, fillstenant_idfrom 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_hqmarker (created byphp artisan leadhub:setup-hq-crm). - Caches the id under
leadhub.hq_tenant_idfor 60 seconds; caches0for "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:
leadshasunique(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 (afind()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_idauto-stamp resolves to null, and reads hitWHERE 0 = 1. Fail-closed, loudly visible in development, safe in production.
Rules for new code
- Any tenant-owned table: add
tenant_id,use BelongsToTenanton the model, composite unique (tenant_id+ natural key). - Never trust a
tenant_idfrom the request body; bind the context (middleware / job constructor) and let auto-stamp fill it. - In jobs and commands, bind explicitly before touching scoped models:
(the billing/onboarding jobs inapp()->instance('current_tenant', $tenant);app/Jobsfollow this pattern). - In SA pages, opt out only deliberately with
->withoutGlobalScope('tenant'), and never on a query whose results a tenant user can reach. - When writing tests, remember the scope: bind a tenant or assert the empty-result behavior intentionally — see Testing the platform.
Related guides
Was this page helpful?
Thanks — noted. ✦