Laravel Multi-Tenancy: Architecture, Packages, and Cost Explained
In short
A multi-tenant Laravel application serves several tenants from one codebase, and the data boundary can be drawn in three ways: a shared database with a tenant column as the usually right start, one database per tenant for hard isolation, or one schema per tenant as the middle ground. You can implement it with stancl/tenancy, spatie/laravel-multitenancy or, with few known tenants, without a package. The critical parts are scopes, queues, caches, files and tests, not tenant identification.
As soon as a web application is meant to serve more than one customer, the question comes up: one installation per customer, or one application for all of them? The second option is called multi-tenancy, and it is the foundation of almost every SaaS product and many customer portals. If you want to run multiple tenants in one Laravel application, you are building a multi-tenant Laravel application.
Laravel handles it well. The real question is not whether but how, because behind the one term sit very different architectures with very different follow-up costs. This article sorts the options, names the tools, walks through the places where tenant separation breaks in practice, and says honestly when the most elaborate solution is the wrong one.
What multi-tenancy means
A tenant is a customer of your application with their own sealed-off data space: their own users, their own content, often their own subdomain (customer-a.yourproduct.com) and branding. The application itself exists only once. One deployment, one codebase, one release for everyone.
The alternative would be one installation per customer. That sounds like clean separation, but it doesn’t scale: every update has to be rolled out n times, every bugfix n times, every server configuration lives n times. Beyond a handful of customers, the multi-tenant architecture almost always wins, provided it is built properly.
The three architectures for multiple tenants
Multi-tenancy comes down to one question: where does the data boundary run? Three answers have become established:
1. Shared database, tenant column. All tenants share the same tables; every row carries a tenant_id, and the application filters every query on it. In Laravel that typically happens via global Eloquent scopes. This is the simplest and cheapest option: one database, one backup, one migration. Its weakness is also its risk: the separation only exists in application logic. One forgotten scope, one hand-written query without the filter, and tenant A sees tenant B’s data.
2. One database per tenant. Every tenant gets their own database; the application switches connections per request. Isolation becomes structural instead of logical: a forgotten filter can no longer hit foreign data, because it physically lives elsewhere. Exporting, restoring or deleting a single tenant also gets easier. The price: migrations, backups and monitoring multiply with the tenant count, and onboarding a new customer means “provision a database”, not “insert a row”.
3. One schema per tenant. The middle ground, mostly with PostgreSQL: one database, but separate schemas per tenant. Harder separation than the tenant column, less operational overhead than separate databases, but tied to the database system and operationally more demanding than option 1.
The rule of thumb from our project work: option 1 is the right start unless something harder is demanded. Hard isolation requirements rarely come from the technology. They come from contracts, industry rules, or from customers who ask in an audit: “Is our data sitting in the same table as our competitor’s?”
How does Laravel know which tenant it is serving?
Before any scope can do its job, the application has to know which tenant it is working for. That is tenant identification, and it usually runs early, in middleware. Four approaches are common.
Subdomain
customer-a.yourproduct.com: the first part of the hostname identifies the tenant. This is the SaaS classic, because customers get an address of their own while you only need a wildcard certificate and a wildcard DNS record. Reserve names like www, app, admin and api, and validate during sign-up that a subdomain is both valid and still free.
Custom domain
portal.customer-a.com: larger customers want their own domain. Your database then holds a mapping from domain to tenant, and every domain needs its own TLS certificate. That is more work to operate than a wildcard subdomain, but for white-label products it is often a requirement. Rentlytics is domain-based too: each module registers its own domains through Route::domain(...). There it is not dynamic tenant identification, though, but a fixed mapping in code, which we show in detail in the article on the module pattern.
Path
yourproduct.com/customer-a/invoices: the tenant lives in the URL path. It needs neither wildcard DNS nor extra certificates and suits internal tools or prototypes. In return, every route has to sit under a tenant prefix, and every generated link must know the tenant or it points nowhere.
Header or token
APIs and mobile apps often have no domain per tenant. The identifier then travels in a header or in the token, for example as a claim. The important point: never trust a freely settable header without checking that the authenticated user actually belongs to that tenant. A header the client can send at will is a claim, not proof.
Whichever route you take, identification is a security boundary. Check that the signed-in user belongs to the identified tenant and fail the request with a 404 or 403 if not. According to its documentation, stancl/tenancy ships middleware for all four approaches (domain, subdomain, domain or subdomain, path and request data, where the default is the X-Tenant header or a tenant query parameter). The Spatie package includes a DomainTenantFinder and lets you write your own finders extending Spatie\Multitenancy\TenantFinder\TenantFinder.
The tools: stancl/tenancy, Spatie, or no package at all
Two packages have established themselves in the Laravel ecosystem, with opposite philosophies.
stancl/tenancy (“Tenancy for Laravel”) aims to solve multi-tenancy largely automatically. Per its documentation there is an automatic mode in which database connection, cache, filesystem and queues switch into the tenant context (through so-called bootstrappers), and a manual mode in which you scope the data yourself. Single-database and multi-database tenancy are both supported. On top come console commands such as tenants:migrate, tenants:seed and tenants:run, and an event system for the tenant lifecycle. That is powerful, especially for option 2 with separate databases. It also means the package reaches deep into your application, so you should understand what it does before relying on it.
spatie/laravel-multitenancy goes the opposite way. Its documentation calls it an “unopinionated multitenancy package” that should only provide the bare essentials: determine the current tenant, run tasks when tenants switch (such as switching the database connection or prefixing the cache), make queue jobs tenant-aware, and run commands for all tenants via tenants:artisan. Here too, a shared database and multiple databases are possible. Less magic, more ownership.
stancl/tenancy and Spatie compared
| stancl/tenancy | spatie/laravel-multitenancy | |
|---|---|---|
| Approach | Lots of automation via bootstrappers | Lean core, you decide |
| Tenant identification | Middleware for domain, subdomain, path, request data | Tenant finders, DomainTenantFinder included, custom ones possible |
| Database models | Single and multi database | Single and multi database |
| Switching context | Bootstrappers for database, cache, filesystem, queue, Redis | Tasks you configure or write yourself |
| Queues | Queue bootstrapper adds the tenant ID to the job | Jobs made tenant-aware via marker interface or config |
| Console | tenants:migrate, tenants:seed, tenants:run and more |
tenants:artisan |
| Lifecycle | Events and job pipelines for create, migrate, delete | Your own logic, tenant management stays with you |
Neither is “better”. If you want to create many tenants with their own databases at runtime, stancl/tenancy saves a lot of manual work. If you want to stay in control or your requirements diverge from the standard assumptions, Spatie is the more honest foundation. Either way, check before you commit which Laravel and PHP versions the current package release supports.
And sometimes: neither. A tenancy package pays off when tenants come into existence at runtime and have a real lifecycle: self-service registration, their own user worlds, per-tenant billing. If the “tenants” are few and known in code, a package is often the wrong first abstraction. Our Rentlytics project shows what that looks like: several municipal rent-index calculators in one Laravel codebase, separated via module service providers instead of a tenancy package. Each module brings its own routes, views, assets and domains. It is the smallest architecture that protects the real boundaries.
Where multi-tenancy fails: isolation
The biggest risk of a multi-tenant application is not performance and not scaling. It is the data-leak bug: tenant A sees tenant B’s data. Exactly one such bug can cost the trust a SaaS product lives on. That is why isolation does not belong in the “we’ll get it right” category but in the architecture and the tests. The following sections go through the places where it breaks in practice.
Global scopes and their pitfalls
With a shared database, the global Eloquent scope is the central tool. According to the Laravel documentation you implement the Illuminate\Database\Eloquent\Scope interface, attach the scope to the model with #[ScopedBy] or addGlobalScope, and it is applied automatically to Eloquent queries on that model. The counterpart is withoutGlobalScope() or withoutGlobalScopes(), and that is where the pitfalls sit:
- Only Eloquent is scoped.
DB::table('invoices')and raw SQL know nothing about your scope. stancl/tenancy’s documentation makes the same point: direct database queries are not limited to the current tenant. withoutGlobalScopes()is a master key. Called without arguments it also removes the tenant scope. Reports, admin evaluations and “quick fixes” are the typical places. Search the code for it regularly, and when you need the exception, write it deliberately and readably.- Without context, the scope must not let everything through silently. A scope that adds no filter when no tenant is set turns a forgotten context into a data leak. Better to fail closed: no tenant, no rows, or an exception.
- New records need the tenant. When creating, the
tenant_idhas to be set reliably, ideally in the model’screatinghook. - Unique rules and indexes. A validation rule like
unique:posts,slugchecks across all tenants. You need a tenant-aware rule and a unique index overtenant_idplus the column, otherwise tenant A blocks tenant B’s slug.
A minimal skeleton that covers these points:
<?php
namespace App\Tenancy;
final class CurrentTenant
{
private ?int $id = null;
public function id(): ?int
{
return $this->id;
}
public function set(?int $id): void
{
$this->id = $id;
}
}
<?php
namespace App\Models\Scopes;
use App\Tenancy\CurrentTenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$tenantId = app(CurrentTenant::class)->id();
// Without a tenant context: no rows instead of all rows.
if ($tenantId === null) {
$builder->whereRaw('1 = 0');
return;
}
$builder->where($model->qualifyColumn('tenant_id'), $tenantId);
}
}
<?php
namespace App\Models\Concerns;
use App\Models\Scopes\TenantScope;
use App\Tenancy\CurrentTenant;
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope);
static::creating(function ($model) {
$model->tenant_id ??= app(CurrentTenant::class)->id();
});
}
}
You register CurrentTenant in the service container with $this->app->scoped(CurrentTenant::class); and fill it in the middleware that identifies the tenant. Scoped rather than singleton on purpose: according to the Laravel docs, a scoped binding is flushed whenever an Octane worker starts a new request or a queue worker starts a new job. A singleton lives on in the worker process and carries the tenant of the last request along. This is deliberately the simplest form; the packages bring more mature mechanisms of their own.
Queues, jobs, commands and the scheduler
A job does not run inside the request but in a worker process. There is no subdomain and no session. If the job does not know the tenant, your scope either has no context (and returns nothing) or, worse, still holds the context of the previous job if you keep the context in a singleton, because the worker process lives long. The scoped binding above prevents that leftover, but it does not set the right tenant by itself. Two rules help:
- Pass the tenant ID to the job (not just a model whose tenant you hope gets resolved correctly), and set the context at the start.
- Restore the context afterwards, even on failure.
Laravel offers job middleware for this:
<?php
namespace App\Jobs\Middleware;
use App\Tenancy\CurrentTenant;
use Closure;
class RunForTenant
{
public function handle(object $job, Closure $next): void
{
$current = app(CurrentTenant::class);
$previous = $current->id();
$current->set($job->tenantId);
try {
$next($job);
} finally {
$current->set($previous);
}
}
}
In the job itself you return the middleware from the middleware() method: return [new RunForTenant];. The packages take this part off your hands. With stancl/tenancy, the queue bootstrapper adds the tenant ID to the job payload per its documentation and initializes the tenant when the job is processed. With Spatie you either make all jobs tenant-aware through queues_are_tenant_aware_by_default or individually through the TenantAware marker interface. Two details from the Spatie docs that are easy to miss: closures cannot implement an interface, so there you need the global setting or a manual call to $tenant->execute(), and a tenant-aware job whose tenant has been deleted in the meantime fails with CurrentTenantCouldNotBeDeterminedInTenantAwareJob.
Artisan commands and the scheduler have no request either. A command like “send invoices” either has to be invoked per tenant (stancl: tenants:run, Spatie: tenants:artisan) or iterate over all tenants itself and set the context per pass. For the scheduler, the pattern is to keep a slim entry in routes/console.php that queues one job per tenant (Schedule::job(...) is provided for this in the Laravel docs). That way the real work runs in workers with a clean context, and one slow tenant doesn’t block the others.
Caches, files and storage
Cache keys like dashboard.stats are global. Two tenants then share the same entry, and whoever comes first fills the cache for everyone. Put the tenant ID into the key or use a cache prefix. stancl/tenancy uses cache tags for this per its docs and therefore requires a cache store with tag support, such as Redis; a Redis bootstrapper additionally changes the prefix per tenant and requires phpredis (Predis does not work there). Spatie ships a task for prefixing the cache.
Files need the same separation as database rows. Store uploads, generated PDFs and exports under a tenant path and check on delivery that the path belongs to the current tenant. A guessable file path is the same leak in a different disguise. The filesystem bootstrapper of stancl/tenancy adapts Storage, storage_path() and asset(), with logs still ending up in storage/logs according to the docs. Custom disks have to be configured there explicitly.
Mail, broadcasting and search
Three places where tenant context likes to get lost:
- Mails and notifications that go through the queue are rendered in the worker. Branding, sender and links must come from the tenant there. If you don’t pass the context into the job, the mail leaves with the default sender or with the wrong customer’s logo.
- Broadcasting: channel names should contain the tenant ID (for example
tenant.{tenantId}.orders), and the channel authorization must check that the user belongs to that tenant. A global channel likeorderssends events to everyone. - Search with Laravel Scout: per the docs, each model is synced by default with an index named after its table. With a shared database, the records of all tenants therefore land in the same index. You either filter by tenant in every search or use
searchableAs()to define one index per tenant. With Scout’s database engine,searchableAs()has no effect, and your normal Eloquent scope stays authoritative.
Testing tenant isolation
A good test suite actively tries to reach tenant B’s data as tenant A. Such tests are cheap to write and grow more valuable with every feature. Here is an example with Pest for the skeleton above. It assumes factories for Tenant, User and Invoice, and middleware that sets the tenant context from the signed-in user:
<?php
use App\Models\Invoice;
use App\Models\Tenant;
use App\Models\User;
use App\Tenancy\CurrentTenant;
beforeEach(function () {
$this->tenantA = Tenant::factory()->create();
$this->tenantB = Tenant::factory()->create();
// Create tenant B's data without any context set.
$this->invoiceB = Invoice::factory()->create([
'tenant_id' => $this->tenantB->id,
]);
$this->userA = User::factory()->create([
'tenant_id' => $this->tenantA->id,
]);
});
it('does not serve records of another tenant', function () {
$this->actingAs($this->userA)
->get(route('invoices.show', $this->invoiceB))
->assertNotFound();
});
it('does not list records of another tenant', function () {
$this->actingAs($this->userA)
->getJson(route('invoices.index'))
->assertOk()
->assertJsonMissing(['id' => $this->invoiceB->id]);
});
it('returns nothing without a tenant context', function () {
app(CurrentTenant::class)->set(null);
expect(Invoice::count())->toBe(0);
});
The first test works because Laravel uses a normal Eloquent query for route model binding, so the global scope applies and the foreign record is never found. Add tests for everything that outputs data: exports, search, downloads, API endpoints, Livewire components. Every queue feature deserves one more: dispatch a job for tenant A, run it, and check that only A’s data is affected and the context is reset afterwards.
With separate databases, the testing situation changes. In automatic multi-database mode, the stancl documentation advises against :memory: SQLite and the RefreshDatabase trait because the database connection gets switched, and it warns against a blanket Event::fake() because the package relies heavily on events. If you test there, create the tenant in test setup and initialize the tenant context explicitly.
Migrations with one database per tenant
Once every tenant has its own database, there are two kinds of migrations: those for the central (“landlord”) database holding tenants, domains and plans, and those for the tenant databases. Both packages separate the two. With Spatie, landlord migrations live in their own folder and run with php artisan migrate --path=database/migrations/landlord --database=landlord, while tenant migrations run via php artisan tenants:artisan "migrate --database=tenant". With stancl/tenancy there is tenants:migrate (all tenants or targeted via --tenants), plus tenants:rollback and tenants:seed.
What matters in practice:
- Deployments that migrate all tenants. Migration runs have to be part of the deployment, not a manual step. The more tenants, the longer the run takes.
- Backward-compatible migrations. While a deployment moves through all databases, tenants with the old and the new schema are briefly live at the same time. Add columns first; rename or remove them later, once no code touches them anymore.
- Plan for partial failures. If the migration fails at tenant 37 of 120, you need a clear path: which tenant is at which state, and how does the rest catch up? Log per tenant.
- New tenants get the current schema. On creation the whole migration history (or a schema dump) runs, followed by seeds. With stancl/tenancy this can be modelled as a job pipeline of
CreateDatabase,MigrateDatabaseandSeedDatabase, synchronous by default and queued if you ask for it.
With a shared database all of this disappears: one migration for everyone. That is a strong argument for option 1 as long as nothing else is demanded.
Onboarding and offboarding a tenant
A tenant has a lifecycle, and every step belongs in code and tests, not in a wiki page.
Creating. At its core: create the tenant record, assign a subdomain or domain, provision resources (database or schema, storage path), create default data, invite the first administrator. Make creation idempotent and repeatable step by step: if provisioning the database works but the welcome mail doesn’t, you want to repeat only the last step. With self-service sign-up, abuse protection, email confirmation and reserving subdomains belong in the flow.
Export. The tenant asks for their data, at contract end or on request. With one database per tenant, a dump is comparatively simple. With a shared database you need an export that covers every table with a tenant_id and takes the files along. Plan this export early. It is also a good test of whether your data model is cleanly separated: if you can’t write the export without constantly finding special cases, the separation is too soft.
Deleting. Offboarding means: block access, delete data now or after a grace period, clean up files and search indexes, release the domain. Decide beforehand whether you block first (“soft delete with a deadline”) and delete for good later. Backups also contain data of deleted tenants. Clarify with the customer and your data protection officer how long that is acceptable and put it in the contract. We can’t make a statement on legal permissibility in a specific case here. With stancl/tenancy there are the TenantDeleted and DatabaseDeleted events and a DeleteDatabase job for this.
Tenant-specific configuration and branding
Almost every tenant wants something of their own: logo, colors, sender address, language, time zone, enabled modules. Keep that as data on the tenant, not as configuration files in code. Three patterns have worked:
- Settings as a structured column or table, with defaults in code and overrides per tenant.
- Branding values as CSS variables, set in the layout from tenant data, instead of building a separate stylesheet per customer. That keeps the frontend bundle identical and the release process simple.
- Feature flags per tenant for modules or plans, checked in the backend, not just through hidden menu items.
stancl/tenancy offers an optional TenantConfig feature for this: through $storageToConfigMap you map tenant attributes to Laravel config keys that are set when the tenant is initialized. With Spatie you write your own task with makeCurrent() and forgetCurrent(). Be careful with credentials, such as a per-tenant SMTP password: store them encrypted and never log them.
Billing per tenant
Billing is its own work package, not a side effect of multi-tenancy, and we only describe it conceptually here. The key decisions:
- Who pays? The tenant as an organization, not the individual user. Billing address, tax details and payment method hang off the tenant.
- What is billed? A fixed plan, the number of users, usage (records or API calls, say) or a mix. Usage-based billing requires measuring usage per tenant cleanly, which is again an isolation question.
- What happens on non-payment? Dunning levels, suspension, reactivation. That touches onboarding and offboarding and needs clear states on the tenant.
- Trials and plan changes create special cases you have to allow for in the data model.
If you connect a payment provider, model the payment status as an explicit state machine. How we solved that in a Laravel project is described in the article on the VRPay payment status state machine.
What multi-tenancy costs
The honest answer: the tenant logic itself is the smaller part. What gets expensive is everything around it, depending on how much “product” your product is supposed to be.
Onboarding (by hand or self-service with automated provisioning), billing, ongoing operations with separate databases, and the testing strategy are the real cost drivers. On top comes the retrofit trap: multi-tenancy touches data model, auth, jobs and storage at the same time. Building it into a grown single-tenant application afterwards costs several times more than designing for it from the start, even if the first release has exactly one tenant.
Why we still end up with Laravel for products like this almost every time is covered in Why Laravel for Business?. The ecosystem of auth, queues, policies and testing is exactly the foundation multi-tenancy needs.
When you don’t need multi-tenancy
Not every application with several customers needs multi-tenancy. Check honestly first:
- You have few customers who all see the same data. Roles and permissions in a normal application are enough. Multi-tenancy separates data spaces; permissions regulate access within one data space.
- The variants are known in code. A few fixed variants with their own route, branding and data can be solved as modules, as with Rentlytics. No self-service, no tenant lifecycle at runtime.
- Every customer needs their own release. If customers demand different versions, code customizations or separate maintenance windows, one installation per customer is more honest, even though it costs more operations.
- The data isn’t customer data at all. An internal tool for several departments usually needs permissions, not tenants.
The flip side: once customers sign up themselves, manage their users independently and are billed separately, you are in real multi-tenancy territory.
Decision guide by situation
- SaaS product with self-service and a growing customer base → shared database with tenant column as the start, a tenancy package for the lifecycle; separate databases only once contracts or compliance demand them.
- Customer portal for a defined set of clients → usually option 1 with clean scopes; a package only if tenants genuinely have a lifecycle.
- Industry with hard isolation requirements (health, finance, public sector) → database or schema per tenant from day one. The structural separation is the selling point here, not the cost item.
- Few known variants instead of real tenants → no tenancy package, but a module structure, see the Rentlytics pattern.
An honest conclusion
Multi-tenancy is an architecture decision, not a package install. The choice between shared database, schema or database separation should come from your isolation requirements, not from a tool’s feature list. The isolation itself belongs in scopes and tests, not in good intentions, and it has to hold where no request runs: in queues, in the scheduler, in caches, files and search. Get that right from the start, and Laravel gives you a base that carries you from the first customer to a real SaaS.
Planning a SaaS product or customer portal? We build multi-tenant Laravel applications, from the architecture decision to operations. Honest upfront assessment of which variant your case actually needs: tell us about your project. More about how we work on our Laravel agency page.
Frequently Asked Questions
What does multi-tenancy mean in Laravel?
Multi-tenancy means one application and one codebase serve multiple customers, the tenants, whose data stays strictly separated. Each tenant experiences the application as if it existed just for them: their own users, their own data, often their own subdomain and branding. Typical cases are SaaS products and customer portals.
How do I build a Laravel application with multiple tenants?
You need four building blocks: tenant identification (subdomain, domain, path or header), a data strategy (tenant column, schema or a database per tenant), isolation in the application (global scopes, tenant-aware jobs, cache and file paths) and tests that attack the boundary. Whether you use stancl/tenancy, spatie/laravel-multitenancy or your own code depends on whether tenants are created at runtime.
Single database or one database per tenant?
A shared database with a tenant column is simpler to operate and the right starting point for most projects. The separation happens in the application. One database per tenant isolates harder and makes exporting or deleting a single tenant easier, but costs significantly more to operate because migrations, backups and connections multiply with every tenant. Hard isolation requirements from an industry, a contract or a public body are the most common reason to take the more expensive route.
Which tenancy package for Laravel: stancl/tenancy or Spatie?
According to their documentation, both support a shared database and multiple databases. stancl/tenancy automates a great deal (database, cache, filesystem, queues, middleware-based tenant identification) but reaches deep into your application in return. spatie/laravel-multitenancy is deliberately leaner and leaves more decisions to you. With few tenants known in code, no package at all is often the right answer.
How do I test that tenants are properly separated?
Write tests that try, as tenant A, to reach tenant B data: through the ID in the URL, through lists and filters, through exports, search and the API. Also check that nothing is returned without a tenant context and that queue jobs set the right context and reset it afterwards. Such tests are cheap and protect every later change.
When do I not need multi-tenancy?
If your customers see the same data, roles and permissions are enough. With a few fixed variants that have their own route and branding, a module structure is enough, and if every customer demands their own release or code customizations, one installation per customer is more honest. Multi-tenancy pays off when customers sign up themselves, manage their own users and are billed individually.
What does a multi-tenant Laravel application cost?
Multi-tenancy is not a feature you add at the end. It is an architecture decision that touches data model, authentication, jobs, backups and onboarding. Retrofitting it costs several times more than designing for it from the start. The cost drivers are less the tenant logic itself than everything around it: automated onboarding, per-tenant billing, operations and the testing strategy.
Related Services
Related Projects
Founder & Full-Stack Developer
20+ years of web development experience. Specialised in Laravel, WordPress and custom software for mid-sized businesses.