Skip to content
← All articles

A database per tenant: what it really cost me

Sep 27, 2026 · Architecture · 6 min

·By Dimitri Pisarev

My SaaS gives every tenant its own database. That choice buys isolation no code review has to enforce: a product query without an initialized tenant context dies on a missing table instead of returning someone else's rows. This is the story of what the same choice charged me afterwards.

What does a database per tenant actually buy?#

I picked the heaviest option on the menu: stancl/tenancy in multi-database mode, one database per tenant. The central database holds tenants, their domains, my platform users, and the billing ledger. Everything else, users, orders, tickets, sessions, lives in the tenant's own database. Sixty-three migrations run under database/migrations/tenant, and there is no tenant_id column anywhere in the project to forget about.

The payoff shows up in the failure mode. With column scoping, the failure mode of a forgotten where is silent: rows come back, the wrong rows. Here, a request that lands without an initialized tenant context hits a database that has no product tables at all. The query dies with a relation error. Wrong context does not leak data, it refuses to run, and a feature test pins that behavior because a rule only one person remembers is a rule that will be broken.

The default that voids the whole model#

Every tenant database in production gets its own PostgreSQL login role, so a leaked credential has exactly one database's reach. That was the theory. The database had other plans: PostgreSQL grants privileges to PUBLIC by default when objects are created, and the docs list the defaults for a database as "CONNECT and TEMPORARY (create temporary tables) privileges" (privileges chapter). In other words, the role I had just created for tenant A could open tenant B's database out of the box. No bug of mine required. The containment I thought I had bought was a suggestion.

Three lines fix it, and they run for every new tenant:

CREATE ROLE tenant_a9f3_app WITH LOGIN PASSWORD '…';
REVOKE CONNECT ON DATABASE tenant_a9f3 FROM PUBLIC;
GRANT CONNECT ON DATABASE tenant_a9f3 TO tenant_a9f3_app;

The revoke is the line that carries the model. Two smaller lessons came out of the same seam: PostgreSQL refuses CREATE DATABASE inside a transaction (SQLSTATE 25001, which the test setup produces on demand), and since version 15 the public schema no longer gets CREATE by default, so each tenant role needs a GRANT USAGE, CREATE ON SCHEMA public before its migrations can run.

When the scheduler wakes up as the wrong tenant#

The vendor's Tenant::run() swaps the database context for the duration of a callback and restores the old one after it succeeds. Only after it succeeds. schedule:run executes every due task in one process, so I got to think through what happens when task one dies inside tenant A's context and task two starts without anyone switching back. The container still points at tenant A. Task two reads tenant A's tables, writes tenant A's rows, and reports success. I left that scenario in the docblock of the trait that replaced the vendor helper, phrased as a cross-tenant contamination risk, which is a calm name for the worst thing my software could do.

Every tenant-touching command runs through one trait now: per tenant, try, catch, report, continue, and the context ends in a finally no matter how the iteration went. One tenant's exception costs that tenant's run, never the fleet's. The queue needed the same discipline. The database queue driver had to be pinned to the central connection explicitly, or failed jobs landed in whichever database the worker happened to be standing in.

Nothing in the framework knows you are multi-tenant#

Then came the week the framework itself got educated. Echo clients authenticate websocket subscriptions by POSTing to /broadcasting/auth, and Laravel registers that route globally with nothing but the plain web group. On a single-database app that is fine. On mine, the database session handler resolved the session user against the central connection, which has no users table, and every authenticated page on every tenant domain answered 500. The fix was small, the broadcasting routes now sit behind the same tenant identification middleware as everything else, but the diagnosis was a long walk through session handler stack traces.

Laravel Pennant, my feature flag layer, bled in the opposite direction: no errors at all. Its in-memory cache stores the null scope in a bucket shared by the whole process, and a scheduler loop that initializes tenant after tenant would read tenant A's cached flag under tenant B. Nothing throws. The answer is just wrong for one of the two tenants. The countermeasure is one event listener that flushes the flag cache on every tenant switch, plus a test that fails the build if the flush ever disappears.

Even assets took two commits in opposite directions. The tenancy asset helper rewrote asset() until my built SPA never mounted on tenant domains, and later the public storage disk needed that same vendor route to serve tenant files at all. Multi-tenancy multiplies every cache decision too, a theme I worked through for the single-app case in CDN caching for a Laravel app.

Trust at the payment webhook#

Then money raised the stakes: tenants connect the payment providers their market actually uses, and each provider's credentials live encrypted in that tenant's database, write-only in the UI even for me. Their webhooks arrive at one shared route, and the trust model has a strict order: resolve the tenant by domain first, then verify the signature against that tenant's stored secret. A signature that is perfectly valid for tenant A is forged garbage when it shows up on tenant B's domain, and a test in the suite asserts exactly that.

The responses fail closed in a ladder I can explain to support: unknown provider, 404. Configured but never armed with a secret, 503. Signature mismatch, 403. Replays die on a journal that stores the SHA-256 of every verified body and ignores byte-identical redeliveries without settling them twice.

The same corner of the codebase produced my favorite JSON detail. Suspending a tenant that stops paying means flipping a maintenance-mode key on the tenant record, and the vendor docs suggest writing a null value to undo it. On PostgreSQL that keeps the tenant suspended forever: a JSON null inside the data column is not SQL NULL, so every query looking for an absent key still matches. The resume action now removes the key outright, which reads the same on SQLite in tests and on PostgreSQL in production. One driver difference, caught against a dev database instead of by a customer.

What keeps the isolation from rotting#

By now the rules of this architecture live in the test suite more than in my head. One test walks the entire schedule and fails if a command can touch tenant tables without the sweep trait or a spot on a short, explicit allowlist. Another creates real PostgreSQL databases and tries to cross the wall with tenant A's login role, expecting rejection at the door. Others assert that a contextless request gets nowhere and that tenant A's webhook signature is worthless under tenant B's domain. CI holds the model together, which is just as well, because my memory does not scale to every tenant's edge cases.

Would I make the same choice again? Yes, and I would budget for the same surprise: the library hands you the architecture in an afternoon, and the months after that go into teaching every subsystem, scheduler, queue, cache, broadcaster, asset pipeline, that the database under you is not the one the process booted with. This app runs on Laravel 13 while all of it happens, and the release turned out to matter more to my weekdays than a changelog skim suggests (what Laravel 13 actually changed for me).