Skip to content
~/tariqul.islam
/blog

/blog/multi-tenant-saas-architecture

10 Feb 2026 · 2 min read

Database-per-tenant in Laravel: what hundreds of tenants taught me

Shared tables are fine until an enterprise buyer asks where their data lives. Here's the architecture that closed those deals — and the three problems nobody warns you about.

Cover illustration for “Database-per-tenant in Laravel: what hundreds of tenants taught me”

Every multi-tenancy article starts with the same diagram: shared database with a tenant_id column versus database-per-tenant. What they don't tell you is that the choice usually isn't yours. It belongs to your customers' procurement departments.

I've shipped database-per-tenant twice: Aorabooks, a SaaS cloud ERP I owned as TPM, and StampBD, my own SaaS for Bangladesh's stamp vendors — plus customer-facing custom domains on our school platforms (AoraSchool and InfixEdu's SaaS module). Not because it was architecturally fashionable — because buyers kept asking two questions: "Is our data physically separated?" and "Can it run on our domain?" With shared tables, the honest answer to both is no, and the deal goes quiet.

The shape of the thing

One landlord database owns what's global: tenants, domains, plans, billing. Everything else lives in per-tenant databases created at signup. The trick that makes the whole system feel simple is resolving the tenant once, early, and swapping the default connection:

config([
    'database.connections.tenant.database' => $tenant->database_name,
    'cache.prefix' => "tenant_{$tenant->id}",
]);
 
DB::purge('tenant');
DB::setDefaultConnection('tenant');

After that middleware runs, the rest of the application doesn't know multi-tenancy exists. Models query the default connection. Developers on the team write code as if there's one customer. That ignorance is the feature — the moment tenant-awareness leaks into business logic, you've signed up for a category of bug that never stops giving.

The three problems nobody warns you about

Migrations become a fleet operation. Running php artisan migrate against one database is a command. Running it against hundreds is an operation that will partially fail — one tenant's database has a lock, another has data that violates your new constraint. We built a queued migration runner that tracks per-tenant migration state, so a failed tenant can be retried alone instead of re-running the world. Build this before you have fifty tenants, not after.

Queues and caches need the tenant context carried explicitly. A queued job runs long after the request that dispatched it, on a worker that has no host header to inspect. Every job payload carries the tenant id; every worker boot resolves the connection before handling. Forget this once and you'll process one tenant's invoice against another tenant's database — in the best case it crashes, and the best case is rare.

Custom domains are a product feature pretending to be an infrastructure feature. CNAME verification, certificate issuance, the dashboard UX for "your DNS hasn't propagated yet" — that's a couple of weeks of work nobody scoped. Budget for it up front; it's also the feature enterprise customers show their bosses.

Was it worth it?

For B2B with serious buyers — and for anything holding accounting data, like Aorabooks: every time. Provisioning is seconds, fleet-wide schema deploys became one command, and sales got their two yeses. For a B2C product with thousands of small free-tier accounts, I'd choose differently — database-per-tenant overhead is real and you should earn it.

The architecture isn't the hard part. The operational maturity around it is. If you take one thing from this post: invest in the migration runner and the tenant-context plumbing first. They're the bones everything else hangs on.

/blog/multi-tenant-saas-architecture/next

Dealing with this exact problem?

This post exists because a real project hit a real wall. If you're approaching the same wall, a 30-minute conversation now is cheaper than the rewrite later.