The trigger is nearly always the same and always good news: a second customer wants exactly what the first one has. The application was built for one over years, and now it is supposed to serve two.
The obvious route is to stand it up a second time. It works, it takes a day, and it is the most expensive decision in this story. At three customers it is still manageable. At twelve, every upgrade is a project, every bug fix has to be rolled out twelve times, and nobody knows any more which instance carries which special adjustment.
This article describes how multi-tenancy gets retrofitted into a system that was not built for it, and above all the places where it routinely stays leaky.
Three models, and the choice is not a matter of taste
There are three ways to separate tenants, and they differ not in quality but in what they suit.
One database per tenant. The hardest separation, and the only one that does not require trust in your own code: a programming mistake cannot show foreign data, because it is not reachable. The price is operations. Every schema change runs as many times as there are tenants, and a failed run at tenant seven is an incident of its own. Right for a few large customers with strict separation requirements, in healthcare or public sector work.
One schema per tenant in one database. The middle route, and usually the worst one: you get the operational overhead of many schemas and still have one shared database as the bottleneck. There are cases for it, but fewer than the popularity of this route suggests.
A shared structure with a tenant column. The route for many customers, and the only one where a schema change stays a single run. Separation then lives in the code, and that is what the rest of this article is about.
An in-between form is often the best in practice: a shared structure, plus the ability to move one large tenant into a database of its own. That is the same idea as sharding, cut by customer rather than by key range, and it keeps the door open without opening it at the start.
The column is the easy part
When retrofitting, adding the tenant column is an afternoon. The hard part is the guarantee that from now on every single query filters by it, and that cannot be produced through diligence.
A system with eight hundred queries needs eight hundred correct decisions, and one wrong one is enough. The place where it happens is never the main query, it is the report somebody wrote four years ago.
So the filter belongs one level down, where it cannot be forgotten. In the database that means row-level security:
-- The filter lives in the database, not in the code. A forgotten
-- WHERE clause then returns no foreign rows, it returns none.
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY own_tenant_only ON orders
USING (tenant_id = current_setting('app.tenant')::int);
-- Important: enforce it for the table owner too. Without this
-- line the application bypasses its own rule whenever it
-- connects as the owning role.
ALTER TABLE orders FORCE ROW LEVEL SECURITY;That last line is the most common mistake in this setup, and it goes unnoticed: the policy is there, it works in a test with a separate role, and in production it does nothing because the application connects as the owner.
Where the database does not offer this, the data access layer takes the same job. In Doctrine it is a filter that adds a condition to every query; in Eloquent a global scope. That is weaker than the database rule, because raw code gets past it, and still orders of magnitude better than eight hundred individual decisions.
The test that finds the leaks
A guarantee that is not checked holds until the next change. The check for this one is surprisingly simple and built in a day.
The test database gets two tenants, and both get data that can be told apart. Then the existing test suite runs as tenant A, and afterwards one test asserts that no identifier belonging to tenant B appeared in any response.
The most effective addition costs five lines: the second tenant gets the higher identifiers, and a test calls every detail page with the identifier of a record belonging to the other one. The correct answer is "not found". Answering "no access" instead reveals that the record exists, and between tenants who should not know about each other, that is already a disclosure.
What besides the database has to become tenant-aware
Here sits the part that is missing from plans and accounts for half the effort. Multi-tenancy does not end at the database.
The caches. The most dangerous item on the whole list, because it is silent. A key like report_2026_11 serves the second tenant the first one's numbers, and nobody notices, because no error occurs. Every key carries the tenant from now on, without exception.
Files and attachments. Separate storage paths, and never access through a guessable address. An attachment sitting under a sequential number is a leak with advance notice.
The search index. Either one index per tenant or a mandatory filter on every query. An index without a tenant field is the second silent place after the caches.
Background jobs. Every job carries the tenant in its payload, and the worker sets it before doing anything. A nightly run that processes "all orders" is a different statement after the migration than before.
Outbound email. Sender, templates, footer and legal notice belong to the tenant. That is not a technical point, it is the place where a customer notices they are sitting on a shared platform.
Logs and metrics. Both get the tenant as an attribute, or the first question after an incident cannot be answered: did this affect everyone or one?
The groundwork for this is the same as for running more than one copy of an application: Sessions, uploads, state.
The noisy neighbour
With the second customer a problem appears that did not exist before: one can bring the application down for the other without doing anything forbidden.
The export across three years, the import with two hundred thousand rows, the report somebody reloads every ten seconds: all legitimate, and all consuming capacity the other one then lacks.
The answer is not architecture, it is two precautions. First, limits per tenant rather than per user, because the bill is drawn up at the customer level. Second, separate queues for long-running work, so a tenant with five hundred queued jobs does not stack everybody else's behind them. Both take a week to set up and prevent the incident you would otherwise meet in person.
Why the silent references in the schema cause so much work here is covered in The real legacy is your database schema.
The order of the migration
There is a sequence for retrofitting that keeps the risk small, and its core is that the existing customer becomes a tenant first, long before a second one arrives.
First every table gets the column and every existing row gets the value one. Then the tenant gets set in the context and filtering is switched on while there is still only one: mistakes now show up as missing data, not as foreign data. Then the list from the previous section, point by point. And only after that does a second tenant get created, with test data at first.
For a mid-sized system the effort is six to ten weeks, with the database as the smaller part. And the one rule that keeps the state afterwards is the same as for any other guarantee: it has to be checked, or it is a claim after three months. How I build multi-tenant platforms is described on its own page.
This article belongs to a series about systems that already exist. The retrospective orders every article in it by situation.

