Designing a Multi-Tenant SaaS Platform with Laravel
Most articles on multi-tenancy start with a diagram of three isolation strategies — separate databases, separate schemas, shared schema with a tenant column — and stop there. In practice, choosing one is the easy part. The hard part is everything that follows: query safety, provisioning, and making sure a tenant boundary bug can never ship to production.
This is the approach I used building a multi-tenant diagnostic-center management SaaS in Laravel, where healthcare data isolation isn't optional.
Start with the isolation guarantee, not the schema
Before picking a strategy, decide what "isolation" has to guarantee. For a healthcare SaaS, the requirement was simple to state and hard to get wrong: no query should ever be able to return another tenant's data, even by accident.
That requirement ruled out "remember to add where tenant_id = ? to every query" as a strategy. Humans forget. Code review misses things. The isolation needs to be structural, not a convention.
Shared schema, enforced at the query layer
I went with a shared-schema, shared-database approach — one set of tables, a tenant_id column on every tenant-scoped table — for cost and operational simplicity. A separate database per tenant is the strongest isolation guarantee, but it turns "add a new diagnostic center" into a database-provisioning operation, and it multiplies migration and backup overhead linearly with tenant count.
To make the shared-schema approach safe, tenant scoping happens automatically, not manually:
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if ($tenantId = app(TenantContext::class)->id()) {
$builder->where($model->getTable() . '.tenant_id', $tenantId);
}
}
}
Every tenant-scoped model applies this as a global scope in its booted() method. The practical effect: a developer writing Patient::where('status', 'active')->get() gets tenant-scoped results by default. They'd have to deliberately opt out to leak across tenants — which is a much safer failure mode than opting in.
Provisioning a new tenant should be boring
If onboarding a new customer requires a deployment, you don't have a SaaS — you have a templated codebase you redeploy per client. The test I used: can a new diagnostic center go live without touching the app's main branch?
That meant provisioning had to be pure data:
- Create a
tenantsrow with a slug, config, and status. - Seed the tenant's baseline reference data (test catalogs, default roles).
- No new subdomain deployment, no new environment variables, no new container.
The trade-off that's easy to underestimate
Shared-schema multi-tenancy makes one thing harder: noisy-neighbor performance. A tenant with an unusually large dataset can slow down queries for everyone if your indexes aren't built around the tenant_id prefix. Every compound index in the schema starts with tenant_id for exactly this reason — it keeps each tenant's data physically clustered within the index, so one tenant's growth doesn't degrade another's query plans.
What I'd tell a team starting this today
Pick shared-schema multi-tenancy by default — it's cheaper to run and easier to reason about at moderate scale. But treat tenant scoping as a framework-level guarantee, not a coding convention, and make provisioning a data operation from day one. Those two decisions matter far more than which isolation diagram you started from.