How we build multi-tenant Laravel apps: schema per tenant vs. row-level scoping

Row-level scoping or a PostgreSQL schema per tenant? How we choose, the code that enforces it and the tests that prove no tenant can see another’s data.

Laravel
Three ways to isolate tenants. This article compares the first two in detail.
On this page

What multi-tenancy means in practice

A multi-tenant app serves many customers from one codebase. Each customer, or tenant, sees only its own users, records and settings. The hard part is not routing a request to the right tenant. It is making sure that no query, job, cache key or file path ever crosses from one tenant into another.

In Laravel there are three common ways to draw that line. We use the first two in production, and pick between them in the discovery phase of every Laravel project.

Option 1: one database, row-level scoping

Every tenant-owned table gets a tenant_id column, and a global scope adds it to every query. New tables, new models and new developers all have to follow the same rule, so we enforce it in one place.

app/Models/Scopes/BelongsToTenantScope.phpPHP
class BelongsToTenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        $builder->where(
            $model->getTable().'.tenant_id',
            tenant()->id
        );
    }
}

This works well for hundreds or thousands of small tenants. Reporting across all tenants is a normal query, and there is only one schema to migrate. The trade-off is that isolation lives in application code: one raw query that forgets the scope can leak data.

Option 2: a PostgreSQL schema per tenant

Each tenant gets its own schema inside the same database. At the start of a request we point the connection at that schema, so tables are physically separate while the server stays shared.

Per requestSQL
SET search_path TO tenant_42, public;

A forgotten filter can no longer return another tenant’s rows, because those rows are not in the schema. The cost is operational: every migration runs once per tenant, and cross-tenant reports need to query each schema.

How the options compare

Three ways to isolate tenants in a Laravel app
Row-level scopingSchema per tenantDatabase per tenant
IsolationEnforced in app codeSeparate schemasSeparate databases
MigrationsOnceOnce per tenantOnce per tenant
Cross-tenant reportsSimplePossibleHard
Best fitMany small tenantsMid-size B2B tenantsFew large, regulated tenants

How we decide

  1. Count the tenants. Thousands of small accounts point to row-level scoping.
  2. Ask about contracts. If customers ask where their data lives, separate schemas are easier to explain.
  3. Look at reporting. If the business needs one view across all tenants, keep one schema or plan a reporting database.
  4. Plan for the move. Starting with row-level scoping and moving large tenants out later is a valid path.

The best tenancy model is the one your team can test, migrate and explain to a customer’s security review.

Testing tenant isolation

Isolation is a feature, so it gets tests. For every tenant-owned model we write a test that logs in as one tenant and tries to read another tenant’s records.

tests/Feature/TenantIsolationTest.phpPest
it('never returns another tenant\'s invoices', function () {
    $a = Tenant::factory()->create();
    $b = Tenant::factory()->create();
    Invoice::factory()->for($b)->create();

    actingAsTenant($a);

    expect(Invoice::count())->toBe(0);
});

These tests run in CI on every pull request, next to the rest of the suite. They are cheap to write and they catch the one mistake that matters most in a multi-tenant app.

Summary

Start with the simplest model that meets your isolation requirements, enforce it in one place and test it like any other feature. If you are planning a multi-tenant platform and want a second opinion on the architecture, send us a short brief.

Frequently asked questions

Which tenancy approach is best for a new SaaS?

For most new SaaS products, row-level scoping is the fastest way to start. Move to a schema per tenant when customers need stronger isolation or when a few tenants grow much larger than the rest.

Can Laravel handle a PostgreSQL schema per tenant?

Yes. You switch the search path at the start of each request or job, and run migrations once per schema. Queues, caches and file storage need the same tenant context.

How do you test that tenants cannot see each other’s data?

For every tenant-owned model we write a test that acts as one tenant and tries to read another tenant’s records. The tests run in CI on every pull request.

Keep reading

All articles

Have a project in mind?

Four questions, two minutes. A fixed-scope proposal within 48 hours.

A fixed-scope proposal within 48 hours.