Hybrid multi-tenancy: shared by default, dedicated on graduation
How we let standard tenants share a database while enterprise tenants get their own — without changing application code.
Healthcare SaaS sits on a tenancy spectrum. On one end, you have small clinics that need cost-effective, shared infrastructure. On the other, you have hospital groups whose compliance team won't sign off on anything less than full database isolation. Most platforms pick a side. Tenelix tries to live on both ends — at the same time, on the same code.
The hybrid model
Every tenant in Tenelix has a tenancy mode:
-- in the central database
tenants (
id uuid primary key,
name text,
is_enterprise boolean, -- if true, dedicated DB
database_name text nullable -- populated when is_enterprise = true
)
Standard tenants share tenelix_tenants_shared. Every operational table
in that database has a tenant_id column, and queries are automatically
scoped to the current tenant via Eloquent global scopes.
Enterprise tenants get their own database — tenant_{uuid} — with the
same schema, but no tenant_id columns. The whole database is the
isolation boundary.
The tricky part: services
A naive implementation works for application data. What breaks is Laravel's own services — sessions, cache, queue. By default, those write to whichever connection is configured globally. So you'd have sessions for an enterprise tenant ending up in the shared database. Audit fail.
We solved this with a HybridServiceConnectionBootstrapper that runs
on tenant initialization. It rebinds the session, cache, and queue
connections to point to the current tenant's database — whether
that's tenelix_tenants_shared or tenant_{uuid}. Application code
keeps using Cache::get() and Auth::user() like nothing changed.
| Service | Shared tenant | Enterprise tenant |
|---|---|---|
| Sessions | tenelix_tenants_shared.sessions | tenant_{uuid}.sessions |
| Cache | tenelix_tenants_shared.cache | tenant_{uuid}.cache |
| Queue | tenelix_tenants_shared.jobs | tenant_{uuid}.jobs |
Migrations
Schema changes work the same way: a new migration file describes the
change, and our deploy step runs it against tenelix_tenants_shared
and every tenant_{uuid} database. Slow? Surprisingly not — most
schema changes are O(seconds) per database, and we run them in
parallel.
Why this matters
Customers don't have to pick a tier upfront. They start on Standard for cost efficiency. When they need stronger isolation — for compliance, for performance, for sovereignty — they graduate. We migrate their data, the bootstrapper rebinds, and they're on dedicated infra without changing their application footprint.
The same model lets us ship a self-hosted Docker stack: the customer's
single tenant runs on tenant_{their_uuid}, just on their own
PostgreSQL. Same image, same code.
That portability — between deployment models, between tenancy modes — is the most important architectural property we have. We optimized hard for it from day one, and it pays off every time a customer's requirements change.