Web Development 5-8 minutes

Multi-Tenancy in Laravel 13: How to Isolate Each Customer's Data in Your SaaS

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Share:
Multi-Tenancy in Laravel 13: How to Isolate Each Customer's Data in Your SaaS
Image generated with AI

When your Laravel app moves from serving one customer to serving many, data isolation stops being optional: one tenant must never see another tenant's invoices. In Laravel 13 there are three proven patterns — a tenant_id column with scopes, a database per tenant with stancl/tenancy, or a schema per tenant on PostgreSQL — and this guide walks the decision and the code end to end, with tests that prove the isolation.

What Multi-Tenancy Is and Why Your SaaS Needs It

The Problem: One App, Many Customers, Zero Data Leaks

Multi-tenancy is the architecture where one application serves several companies or customers, the tenants, while keeping their data completely separate. It is the point where many SaaS projects get stuck: the app works for one customer, and when the second arrives, the temptation is to copy the database or start mixing records together. The multi-tenant pattern solves that systematically: every tenant operates its own world even when the code, the servers, and even the database are shared.

Tenant, Central Data, and the Platform–Customer Boundary

Before writing code, separate two kinds of information. Central data belongs to the platform: the tenant's own record, its plan, its billing status. Tenant data belongs to each customer's business: their clients, their invoices, their settings. Confusing that boundary is the number one cause of data leaks, because a badly placed model ends up mixing the global with the particular.

Identifying the Tenant: Subdomain, Custom Domain, or Route

Every HTTP request must answer one question: which tenant are we talking about? The most common way is the hostname, with a subdomain per customer like acme.app.test, though custom domains owned by each company are also supported and, in simple cases, a parameter in the route. The mechanism that answers that question is called tenant identification and usually lives in middleware.

The Three Isolation Patterns

Single Database with tenant_id and Global Scopes

The cheapest pattern shares a single database: every tenant-scoped table carries a tenant_id column and queries are always filtered by the current tenant. Its big advantage is operational simplicity; its big demand is discipline, because any query that forgets the filter leaks data between companies.

Database per Tenant: Strong Isolation at an Operational Cost

The opposite pattern gives every tenant its own database. Isolation is physical, backups and restores happen per customer, and enterprise contracts are signed with more peace of mind. The cost is operational: migrations, queues, and maintenance multiply by every tenant, which is why it is almost always automated with a package.

Schema per Tenant with PostgreSQL (and When to Look at RLS)

Between the two extremes sits a middle ground: one database with a schema per tenant, something PostgreSQL handles natively and some Laravel packages support with a dedicated database manager. If you also need guarantees at the server level, PostgreSQL's row-level security lets the database itself enforce the tenant filter.

How to Choose by SaaS Stage, Cost, and Compliance

There is no universally correct pattern, only one that is correct for each stage. With few customers and a tight budget, row-level isolation with tenant_id is enough. When the contract demands strong isolation, per-customer backups, or strict compliance, a database per tenant pays off. The golden rule: choose based on the product's reality and obligations, not on fashion.

Option A: Row-Level Isolation by Hand, No Packages

The tenant_id Column and the Composite Index

Row-level isolation starts in the migrations: every tenant table carries its tenant_id, and the most important index is not on the bare column but the composite one that puts it first, for example together with the invoice id. That leading index makes every tenant query lean on the right filter.

Eloquent Global Scopes: The Safety Belt for Every Query

So that no query forgets the filter, you register a Global Scope on every tenant-scoped model: Eloquent adds it automatically to all queries of that model, like an invisible where that always knows which tenant we are talking about. It is the safety belt of the row-level pattern, because it makes human error impossible for the model's normal queries.

A Middleware to Resolve the Tenant from the Subdomain

The safety belt needs to know who the current tenant is. Middleware reads the request's subdomain, looks up the matching tenant, and makes it available for the rest of the application, for example as a tenant singleton in the container. That piece runs before the controllers and defines the context for the whole request.

Defense in Depth: Why the Scope Alone Is Not Enough

The Global Scope covers that model's Eloquent queries, but not everything: a raw query through DB, a relation loaded through a path that bypasses the model, or a job running outside the request can escape the filter. That is why the recommended practice is defense in depth: scope plus resolution middleware plus isolation tests proving tenant A never sees tenant B's data.

Option B: Database per Tenant with stancl/tenancy

Install the Package: composer require stancl/tenancy

When strong isolation becomes a requirement, the community standard is stancl/tenancy, maintained today as archtechx/tenancy. Its promise is automatic multi-tenancy: you do not need to touch your models to switch connections or replace Laravel's classes with special versions. Installation starts with requiring the package and publishing its configuration.

php artisan tenancy:install and What It Generates

After installing the package, you run php artisan tenancy:install: the command publishes the configuration to config/tenancy.php, creates the platform migrations, and leaves the structure ready to separate both worlds. From there, review the published configuration before touching anything else, because it decides which connections use the central database and which use each tenant's databases.

Central vs Tenant Databases: Separate Migrations

The package's mental model has two migration tracks: platform migrations, which create the central database with the tenants table, their plans, and their global data; and tenant migrations, which define the business schema each customer will have in their own database. Mixing both tracks is the classic first-day mistake: a tenant migration in the central track leaves every client without their table.

Defining the Tenant Model and Creating Your First Tenant

The Tenant model represents each customer and connects to the central database. Creating your first tenant is creating a record of that model with its domain name: the package prepares its database when it is time to migrate. From that record on, the application knows that acme.app.test and globex.app.test are separate worlds.

php artisan tenants:migrate to Push the Schema to Every Tenant

With the tenant migrations written, the command php artisan tenants:migrate applies them to all tenant databases, and it accepts a list of identifiers when you only want to update one customer. The same pattern repeats with seeders through tenants:seed when each tenant needs initial data.

Tenant Identification and Routes

Hostname Identification: Subdomains and Second-Level Domains

The package identifies tenants by hostname: every request resolves to the tenant whose domain matches, and the mechanism supports both your platform's subdomains and second-level domains owned by the customer, the typical case where a company brings its own domain to your SaaS.

Protecting Routes and Tenant Middleware

Tenant-world routes are grouped under the middleware that activates the correct connection. In Laravel you define the group with Route::domain to match the subdomain, and inside it the package's middleware switches the database connection, cache, and storage to the tenant's context before reaching the controller. Anything outside that group is pure platform and must not touch customer data.

Queues, Cache, and Storage in a Per-Tenant World

The database is only the first layer: queues, cache, and files can also leak information between tenants if shared without separation. The package offers mechanisms to make these pieces tenant-aware when you need it, and the decision to share or separate them should be made per piece: shared cache with tenant-scoped keys can be acceptable, shared storage without partitioning almost never is.

The Minimal Alternative: spatie/laravel-multitenancy

Tenant Finder and Separate Landlord/Tenant Migrations

If stancl/tenancy's weight is not justified, spatie/laravel-multitenancy offers the essentials: a tenant finder that determines which tenant matches each request, separate landlord and tenant migrations, and just enough logic for the app to know which context it runs in. It is the minimal option for those who want to understand every piece without magic.

When to Pick Spatie Instead of stancl/tenancy

The choice depends on the isolation you need. Spatie shines when the row-level pattern with tenant_id falls short but you do not yet need a database per tenant with all its operations, or when you prefer to build resolution and connection handling with your own hands. stancl/tenancy wins when you want automatic database-per-tenant, with hostname identification, per-customer migrations, and an ecosystem already solved.

Testing the Isolation with Pest

Testing Tenant Resolution by Subdomain

Isolation tests are the proof that the architecture works. The first one verifies resolution: a request to acme.app.test activates the Acme tenant and one to globex.app.test activates Globex, never the other way around. If the middleware fails, this test catches it before any customer does.

Testing That Tenant A Never Sees Tenant B's Data

The test that gives the SaaS its value is the no-leak one: with two tenants created and data of their own in each, an Eloquent query run in tenant A's context returns only A's rows, and a route under A's subdomain never exposes B's records. That test is written once and stays forever as a safety net against the day someone forgets a filter.

Testing Migrations Applied to a Specific Tenant

The third test covers operations: it verifies that tenant migrations apply to the indicated tenant's database and that its schema contains the expected business tables. That way you catch early the classic error of a new migration left in the wrong track.

Conclusion

Multi-tenancy in Laravel 13 is decided before it is written: row-level isolation with tenant_id and Global Scopes to start cheap, a database per tenant with stancl/tenancy when strong isolation and per-customer backups are requirements, and a schema per tenant on PostgreSQL as the middle ground. Whatever you choose, isolation is proven with tests, not good intentions: tenant resolution by subdomain, no leaks between customers, and migrations applied correctly. With that triad, your app can grow from one customer to a hundred without one tenant's data showing up in another's dashboard.

Categories