Multi-tenancy is the decision that silently sets your operational costs for the next several years. Pick shared tables and you get cheap onboarding with a permanent risk of cross-tenant leakage. Pick a database per tenant and you get strong isolation with a migration process that must succeed across hundreds of databases.
There is no default answer, but there is a good question: what does your largest customer's security questionnaire require, and how many tenants do you expect. Those two answers narrow it immediately.
The three models
Shared schema with a tenant_id column is the cheapest to operate. One database, one migration run, trivial onboarding, and the best resource utilisation. The cost is that isolation is enforced entirely by your code, so one query missing a scope is a data breach rather than a bug.
Database per tenant inverts every one of those properties. Isolation is structural and easy to demonstrate to an auditor, per-tenant restore is trivial, and a noisy tenant cannot affect others. In exchange, migrations become a fleet operation, connection pooling gets complicated, and cross-tenant reporting needs a separate pipeline. Past a few hundred tenants it becomes a platform engineering project in its own right.
Schema per tenant on PostgreSQL sits between them and is underrated for mid-sized B2B products: one database and connection pool, but genuinely separate tables, so an unscoped query fails rather than returning someone else's rows.
If you choose shared schema, make the scope impossible to forget
// A global scope is necessary and not sufficient — it is bypassed by
// withoutGlobalScopes(), raw queries, and any model that forgot the trait.
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope('tenant', function (Builder $query) {
if ($tenant = Tenant::current()) {
$query->where($query->getModel()->getTable().'.tenant_id', $tenant->id);
}
});
static::creating(function (Model $model) {
$model->tenant_id ??= Tenant::current()?->id
?? throw new RuntimeException('No tenant context for write');
});
}
}
// The real safety net is at the database, where application bugs cannot reach:
// ALTER TABLE inspections ENABLE ROW LEVEL SECURITY;
// CREATE POLICY tenant_isolation ON inspections
// USING (tenant_id = current_setting('app.tenant_id')::uuid);
// ...with the connection setting app.tenant_id on checkout from the pool.Row-level security is the control I would not ship a shared-schema product without now. It costs a little performance and it means that the day someone writes a raw query without a scope — and someone will — the database refuses rather than returning another customer's data.
The operational details that bite later
- Queued jobs lose tenant context. Serialise the tenant ID into the job and restore it in the handler, or your worker writes to whichever tenant was last active.
- Caches must be tenant-keyed. A cache key without the tenant is a cross-tenant leak with a very fast delivery mechanism.
- Test isolation explicitly: a test suite that asserts tenant B cannot see tenant A's records, run in CI on every change.
- Plan tenant deletion early. In shared schema it is a careful cascade; in database-per-tenant it is a drop, which is one genuine advantage.
- Watch for the largest tenant. Shared infrastructure eventually meets a customer ten times bigger than the rest, and the answer is usually to move just them.
In shared-schema multi-tenancy, every query is one forgotten WHERE clause away from being an incident report.
My general recommendation for B2B SaaS: shared schema with row-level security as the default, and a documented path to move an individual tenant onto a dedicated database when a contract requires it. That keeps onboarding cheap for the many while giving sales a real answer for the few who ask — which, in my experience, is the shape the business actually needs.