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.
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.
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
| Row-level scoping | Schema per tenant | Database per tenant | |
|---|---|---|---|
| Isolation | Enforced in app code | Separate schemas | Separate databases |
| Migrations | Once | Once per tenant | Once per tenant |
| Cross-tenant reports | Simple | Possible | Hard |
| Best fit | Many small tenants | Mid-size B2B tenants | Few large, regulated tenants |
How we decide
- Count the tenants. Thousands of small accounts point to row-level scoping.
- Ask about contracts. If customers ask where their data lives, separate schemas are easier to explain.
- Look at reporting. If the business needs one view across all tenants, keep one schema or plan a reporting database.
- 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.
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.


