Skip to content

Decide how tenancy works before you write the first migration

Multi-tenancy is one of the few decisions in a new SaaS product that is genuinely expensive to change later. This post covers the three common models, how to pick one, and how to implement shared-schema tenancy in Laravel so it does not leak.

Laravel, Architecture, Security, SaaS

A support ticket arrives eight months after launch. A customer has spotted an invoice reference in an export that is not theirs. Nothing was hacked. Someone added a reporting query, wrote it against the raw query builder instead of the model, and forgot the where tenant_id = ?. It shipped, because no test would have caught it.

That bug is cheap to fix and expensive to prevent, and the reason is almost always the same: tenancy was added to the codebase gradually, by convention, rather than decided up front and enforced by the framework and the database.

This is one of a small handful of decisions in a new product that you cannot quietly revisit. Most architecture can be deferred. You can start with a monolith and pull services out later. You can start with a single queue worker and add more. You can swap a payment provider. Tenancy touches every table, every query, every background job, every file path and every cache key. Retrofitting it is a project, not a refactor.

The three models

Shared schema, tenant column. One database, one set of tables, every row carries a tenant_id. This is what most SaaS products should start with. Migrations run once. Cross-tenant admin reporting is a normal query. Cost per tenant is close to zero, which matters if you have a free tier or expect thousands of small accounts.

Database per tenant. Each customer gets their own database (or schema, in PostgreSQL). Isolation is structural rather than a matter of discipline. Restoring one customer's data to last Tuesday is a routine operation instead of a surgical one. The costs are real: migrations have to run N times and can fail halfway through, connection handling gets more complex, and any query that spans tenants becomes a loop or a separate warehouse.

Hybrid. Shared schema for most customers, dedicated databases for the ones who demand it. This is where a lot of mature products end up, and it is the most painful place to arrive at by accident. If you think you will get there, build the tenant resolution layer so that the connection is a property of the tenant from day one, even if every tenant resolves to the same connection at first.

How to choose

A few questions usually settle it.

Do users ever belong to more than one tenant, and do they need to move between them in one session? If yes, shared schema is much easier. Switching databases mid-request is doable but it makes caching, authorisation and session handling fiddly.

Will you be asked for a signed statement that customer data is physically separated? Enterprise procurement in regulated sectors does ask. If your first three target customers are NHS trusts or financial services firms, find out before you build. The answer changes the model.

How many tenants do you expect, and how big is the largest? Fifty tenants with millions of rows each points towards separate databases. Five thousand tenants with a few thousand rows each points firmly at a shared schema.

Does anyone need per-tenant restore or per-tenant export of everything? That is much harder in a shared schema, though not impossible if every table is properly scoped.

If the answers are all "no" or "don't know", start with shared schema and make the boundary explicit. That is the default for a reason.

Making shared schema actually hold

The problem with a tenant column is that isolation depends on every developer remembering it, including the one who joins in a year and is under pressure. So do not depend on memory. Put it in three places: the database, the model layer, and the tests.

In the database, the tenant column is not nullable, it has a foreign key, and every unique constraint includes it:

Schema::create('invoices', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained();
    $table->string('reference');
    $table->unsignedInteger('total_pence');
    $table->timestamps();

    $table->unique(['tenant_id', 'reference']);
});

That composite unique index is the detail people miss. A global unique on reference means one customer's invoice numbering constrains another's. You will discover this when a customer imports their historical invoices and half of them are rejected.

In the model layer, one trait, applied to everything:

trait BelongsToTenant
{
    public static function bootBelongsToTenant(): void
    {
        static::addGlobalScope(new TenantScope());

        static::creating(function (Model $model) {
            $model->tenant_id ??= Tenancy::current()?->id;
        });
    }
}

The global scope handles reads. The creating hook handles writes. Both matter: a scope alone will happily let you create an orphan row that nobody can ever see again.

Then add a test that asserts the boundary, not the feature:

it('does not expose another tenant\'s invoices', function () {
    $mine = Invoice::factory()->for($tenantA)->create();
    $theirs = Invoice::factory()->for($tenantB)->create();

    Tenancy::use($tenantA);

    expect(Invoice::pluck('id'))->toContain($mine->id)
        ->not->toContain($theirs->id);
});

Write one of these per resource. They are dull and they are the tests that pay for themselves.

The places tenancy leaks

The request lifecycle is the easy part, because tenancy resolves from a subdomain or the authenticated user and middleware sets it. The leaks happen everywhere else.

Queued jobs. A job serialised in a web request and run by a worker has no tenant context unless you put it there. Pass the tenant id explicitly in the constructor and set it in handle(), or use a job middleware that does it for you. Do not rely on a serialised model restoring the context, because global scopes will block the restore and you will get a confusing ModelNotFoundException.

Console commands and scheduled tasks. A nightly command has no tenant. It either loops over tenants deliberately or it needs an explicit unscoped query. Make that unscoping loud: a method named withoutTenantScope() that a reviewer will notice.

Cache keys. cache()->remember('dashboard_stats', ...) is a cross-tenant data leak waiting to happen. Prefix every key with the tenant id, ideally through a wrapper so nobody has to remember.

File storage. Put the tenant id in the path. It makes per-tenant deletion and export tractable, and it means a signed URL guessed by an attacker lands in the wrong directory rather than someone else's uploads.

Broadcast channels, webhooks, full text search indexes, PDF filenames. Same pattern. Anywhere you build a string from user data, the tenant belongs in it.

What it costs

Being honest about the tradeoff: doing this properly at the start adds maybe two or three days to the first version. You will write tests for a boundary that no customer has asked about. The tenant scope will occasionally get in your way when you are debugging in tinker and cannot see the row you just created.

Against that, the cost of retrofitting is a full audit of every query in the codebase, a data migration to backfill tenant ids, and a period where you cannot be certain the isolation holds. That is the kind of work that stalls a roadmap for a quarter, and it tends to land exactly when you have just signed the customer who triggered the question.

What to check this week

If you are about to start building, write down your tenancy model in one paragraph and put it in the repository README. Name the resolution mechanism (subdomain, path, user relationship) and the enforcement mechanism (global scope, separate connection).

If you already have something running, pick three tables and check: is the tenant column non-nullable, are the unique indexes composite, and is there a test that proves tenant A cannot read tenant B's rows? Then grep for DB::table(, ->withoutGlobalScopes() and cache()-> and read every result. That is usually where the surprises are.

Keep reading

Tell us what you are building

A short conversation, let's get to know each other. If we are not the right people we will say so.

Start a conversation