· 2 min read · Zain Akram
Multi-tenant SaaS on one schema: the rules that kept 50,000 customers separate
Why we chose a shared database with hard tenant scoping over database-per-tenant for a federal-program CRM, and the five rules that made it safe.
- Architecture
- SaaS
- Laravel
- MySQL
The CRM served about ten internet providers enrolling customers into the US Affordable Connectivity Program. Each provider needed complete data separation, the team was small, and onboarding a new provider had to be a configuration task, not a project.
The classic choice is database-per-tenant or shared schema. We went shared, and I still think it was right. Here is the reasoning and the rules.
Why not a database per tenant
It is the "obviously safe" option, and it is also the one that quietly eats a small team. Every migration runs N times. Every backup and restore is N operations. Cross-tenant reporting for the program itself (which the federal side required) becomes an ETL project. With ten providers it was manageable; the roadmap said fifty.
Shared schema puts the risk in one place, application code, where you can test it relentlessly.
The five rules
1. Every tenant-owned table has a `tenant_id`, no exceptions. Not "most tables". A table without a tenant id is either global reference data or a bug, and the schema linter fails the build if it finds one it does not recognise.
2. Scoping is global and automatic. In Laravel this is a global query scope applied by a trait on every tenant model. The current tenant comes from the authenticated session, never from a request parameter. Developers cannot forget it, because they never write it.
3. Writes are scoped too. Creating a record sets tenant_id from context, and mass assignment of tenant_id is forbidden. The only code path that writes a different tenant's id is the admin import tool, and it is boring on purpose.
4. Sensitive fields are encrypted per tenant. Customer identity fields are encrypted with a per-tenant key. A query bug that leaks rows leaks ciphertext.
5. Tests prove isolation, not just features. Every feature test runs twice with two tenants and asserts that tenant B cannot see, change or count tenant A's data, including through search, exports and reports. These tests found three real bugs before customers did.
Roles on top of tenancy
Within a tenant, RBAC decides what a user can do. Keeping tenancy (which data exists for you) and authorization (what you may do with it) as two separate layers made both easier to reason about and easier to audit.
The result
The system was tested with 50,000+ customers across about ten providers. Onboarding a new provider became a configuration record plus branding. External audits found zero cross-tenant leaks. The migration story stayed one migration, one database, every time.
If your team is small and your tenant count is going up, shared schema with rules enforced in code is usually the better bet. The trick is treating those rules as infrastructure, not as something each developer remembers.