Multi-tenancy in Laravel means serving multiple clients from a single codebase, with each tenant's data isolated from every other tenant. The critical constraint is not features or routing; it is preventing data leakage. Every query, every cache key, every queued job, and every file upload must be scoped to the correct tenant. Get this wrong and customers see each other's data. The failure is silent until someone notices.
We have built multi-tenant applications since 2005, and have used Laravel for them since the framework's early days. Across more than 50 projects, the patterns that follow are the architectural decisions, failure modes, and production concerns that tutorials consistently skip.
Why Laravel Multi Tenancy Is Harder Than It Looks
The typical introduction to multi-tenancy starts with a tenant_id column and a WHERE clause. That works for the first three models. By the time an application has 40 Eloquent models, 15 queued jobs, scheduled commands, file uploads, cache layers, and webhook handlers, the manual approach will produce a data leakage bug. Without structural guardrails, it is a matter of time.
The most common failure mode: a developer writes a query without the tenant filter. In a shared database, that query silently returns data from every tenant. No exception is thrown. No test fails unless you wrote one specifically for isolation. The customer who discovers the problem is rarely the first to be affected; they are just the first to notice.
The core constraint: The architecture should make it structurally difficult to accidentally query across tenants, not merely rely on developers remembering to add a filter.
Database Strategies: Per-Tenant Versus Shared
There are two dominant approaches to tenant data isolation. A third option (schema-per-tenant, where each tenant gets their own PostgreSQL schema within a shared database) exists but is rarely used in Laravel because Eloquent's connection handling makes it awkward to maintain. In practice, the decision comes down to physical separation versus logical separation.
| Concern | Database Per Tenant | Shared Database (Tenant Column) |
|---|---|---|
| Data isolation | Complete. Physical separation. | Logical. Relies on application-layer scoping. |
| Operational complexity | High. Each tenant needs its own connection, migrations, backups. | Low. One database, one migration run. |
| Cross-tenant reporting | Difficult. Requires querying across connections. | Possible via dedicated reporting queries with explicit authorisation. Never bypass scopes in application code. |
| Tenant count ceiling | In our experience, practical limit around 50-200 databases before connection pooling and migration orchestration become burdensome. | Thousands of tenants on one database. |
| Migration complexity | Must run migrations against every database. | One migration run. Add tenant_id to new tables. |
| Per-tenant backup/export | Simple. Each database is self-contained. | Requires custom export logic to extract one tenant's rows across all tables. |
For most Laravel applications, the shared database with tenant column is the right starting point. It keeps operational complexity low, works well up to thousands of tenants, and avoids the connection pooling overhead that database-per-tenant introduces. The trade-off is that per-tenant backup, export, and deletion require custom tooling, and composite unique constraints (like tenant_id, email) replace simple unique indexes.
Switch to database-per-tenant when regulatory requirements demand physical data separation, when individual tenants generate enough load to justify dedicated resources, or when tenant data sizes vary so dramatically that shared indexes become inefficient.
In our experience, the shared database approach with Eloquent global scopes covers roughly 80% of use cases. The remaining 20% typically involve healthcare, financial services, or government contracts where physical separation is a compliance requirement.
Choosing a Multi-Tenancy Package: Spatie Versus Stancl
Two packages dominate the Laravel multi-tenancy space: Spatie's laravel-multitenancy and Stancl's Tenancy for Laravel. Both are well-maintained. They differ in philosophy.
| Concern | Spatie | Stancl |
|---|---|---|
| Philosophy | Minimal, task-based. You compose the behaviour you need. | Full-featured, automatic. Convention over configuration. |
| Queue integration | Opt-in. Tenant-aware queues via configuration and marker interfaces. | Automatic by default when bootstrappers are configured. Less boilerplate. |
| Learning curve | Lower. Less magic, more explicit code. | Higher initially, less boilerplate once configured. |
| Best for | Teams wanting full control who understand the trade-offs. | Teams wanting batteries-included when their model fits conventions. |
A third option: build it yourself. For teams comfortable with Laravel's middleware, model events, and global scopes, a custom implementation can be clearer than either package. The core code is typically 5-8 classes: a Tenant model, a context singleton, a middleware, a global scope, a trait, and a job base class. The trade-off is that you take on the maintenance burden for edge cases that packages have already solved: Octane compatibility, broadcasting channel scoping, testing helpers, and artisan command integration.
We have used all three approaches. For projects where the team has strong Laravel internals knowledge and will maintain the application long-term, a custom implementation gives full visibility into tenant resolution. For teams that want to move quickly or where multi-tenancy patterns are new, a package provides guardrails that prevent common mistakes. The right choice depends as much on the team's experience as it does on the project's requirements.
Tenant Identification Strategies
Before the application can scope data, it needs to know which tenant the request belongs to. There are four common identification strategies.
Subdomain identification
acme.yourapp.com, globex.yourapp.com. The cleanest approach for most SaaS applications. Each tenant gets a unique subdomain. A wildcard A record covers all tenants. DNS is simple.
Custom domain identification
app.acmecorp.com. Essential for white-label products. Each tenant configures a CNAME record pointing to your application. SSL certificate management (typically via Let's Encrypt with HTTP-01 challenges) is the operational cost.
Path-based identification
yourapp.com/acme/dashboard. Works for internal tools and admin panels where tenant switching is common. Route registration requires care to avoid conflicts with global routes.
Header-based identification
X-Tenant-ID or a claim within a JWT. Suits API-only applications. The server must validate that the authenticated user belongs to the claimed tenant; a client-supplied header alone is not a trusted source. Use a signed JWT claim or validate the header against the user's tenant memberships on every request.
The middleware for any of these approaches follows the same pattern: resolve the tenant, set context, process the request, and reset context in a try/finally block. The try/finally is critical. Without it, if the request throws an exception, the tenant context leaks into the next request when using persistent workers like Octane or Swoole.
// TenantMiddleware.php
public function handle(Request $request, Closure $next): Response
{
$tenant = Tenant::where('domain', $request->getHost())
->firstOrFail();
TenantContext::set($tenant);
try {
return $next($request);
} finally {
TenantContext::clear();
}
}
Session and cookie isolation: When using subdomains, configure the session cookie domain to the specific subdomain (not a wildcard like .yourapp.com), or a session created on one tenant's subdomain will be valid on every other tenant's subdomain. Verify on every authenticated request that the logged-in user belongs to the current tenant. A valid session cookie combined with a different subdomain should not grant access.
Data Isolation with Eloquent Global Scopes
Once the tenant context is set, every Eloquent query against a tenant-scoped model must filter by tenant_id. Doing this manually is the naive approach and the source of every data leakage bug we have seen.
The production pattern uses a global scope and a trait. Apply the trait to every model that holds tenant data directly. The scope filters reads; the creating hook ensures writes are assigned to the correct tenant without the developer remembering to set it. Child models that inherit tenancy through a parent relationship (like order items belonging to an order) can either carry a denormalised tenant_id for query safety, or be scoped via their parent's relationship. Either approach works; the important thing is to test both paths explicitly.
The following examples assume shared-database tenancy with a tenant_id column. For database-per-tenant architectures, the tenant resolution middleware would also switch database connections, cache prefixes, and filesystem roots.
// BelongsToTenant trait (shared-database pattern)
trait BelongsToTenant
{
protected static function bootBelongsToTenant(): void
{
static::addGlobalScope(new TenantScope);
static::creating(function (Model $model) {
if (! TenantContext::id()) {
throw new TenantContextMissing(
'Cannot create ' . class_basename($model) . ' without tenant context.'
);
}
$model->tenant_id = $model->tenant_id ?: TenantContext::id();
});
}
}
// Usage: class Order extends Model { use BelongsToTenant; }
What NOT to scope: Not every model belongs to a tenant. System configuration, plan definitions, feature flags, country lists, and other reference data must remain globally accessible. Mark these models clearly. We use a GlobalModel trait (which does nothing functionally, but acts as documentation) to signal that a model is intentionally unscoped.
The raw query gap: Global scopes only protect Eloquent queries. Any raw SQL, DB::table() calls, or third-party packages that bypass Eloquent will not apply the tenant filter. Audit every raw query in the codebase. We have caught data leakage bugs in raw reporting queries on three separate projects.
Isolation boundaries beyond Eloquent
Global scopes handle the majority of tenant isolation, but several other Laravel features bypass Eloquent entirely and need explicit tenant awareness.
Route model binding
Laravel's implicit model binding resolves /orders/{order} by running Order::findOrFail($id). If the global scope is applied, this is safe. If it is not (or if you use withoutGlobalScopes() anywhere in the resolution chain), a user can access another tenant's records by changing the URL. Always verify that scoped models are resolved within tenant context.
Validation rules
Rule::unique('users', 'email') checks against the entire table unless you add ->where('tenant_id', $tenantId). For applications with tenant-owned users, this means one tenant's email address blocks registration for every other tenant. The same applies to Rule::exists(). (Applications using central users with a membership table may intentionally want global email uniqueness.)
Composite unique constraints
Database-level unique indexes must include tenant_id. A unique constraint on email alone means two tenants cannot have users with the same email address. The correct constraint is unique(tenant_id, email).
withoutGlobalScopes()
Any call to withoutGlobalScopes() or withoutGlobalScope(TenantScope::class) removes tenant isolation. These calls are sometimes necessary (admin panels, reporting, super-admin access) but each one should be audited and wrapped in an authorisation check.
Queue and Job Isolation
Background jobs are the most common source of multi-tenancy bugs. The reason is straightforward: queued jobs run outside the HTTP request lifecycle. There is no middleware. There is no tenant context. Unless the job explicitly carries and restores the tenant ID, it runs in a global context.
The pattern we use on every multi-tenant project: an abstract TenantAwareJob base class that captures the tenant ID at dispatch time and restores it before execution, with a try/finally block to ensure cleanup even if the job fails.
// TenantAwareJob base class (shared-database pattern)
abstract class TenantAwareJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tenantId;
public function __construct()
{
$this->tenantId = TenantContext::requireId();
}
public function handle(): void
{
$tenant = Tenant::findOrFail($this->tenantId);
TenantContext::set($tenant);
try {
$this->execute();
} finally {
TenantContext::clear();
}
}
abstract protected function execute(): void;
}
// Not every job is tenant-aware. Central jobs (billing aggregation,
// system maintenance) should not extend this class. Classify every
// job explicitly as tenant-scoped or central.
Scheduled tasks require a different pattern. A scheduled command typically needs to run for every tenant. The approach is to iterate through active tenants, setting and resetting context for each. Use cursor() to avoid loading all tenants into memory. Wrap each tenant's work in a catch block so that one tenant's failure does not block the rest.
Other async contexts need the same treatment. Event listeners, notification channels, mailables, webhook dispatch handlers, broadcasting channel authorisation, and search index updates all run code that may touch tenant data. Each one needs tenant context set before execution. If the application uses Laravel Scout, the search driver must scope indexes or index entries by tenant. If it uses broadcasting, channel authorisation callbacks must verify the tenant matches the authenticated user's tenant.
Catch per tenant. Always. We have seen a single tenant with corrupt data crash a nightly billing run that affected 200 other tenants. Isolate failures at the tenant level.
Testing Multi-Tenant Applications
Standard feature tests do not catch multi-tenancy bugs. A test that creates data, queries it, and asserts the result will pass even without tenant scoping, because there is only one tenant in the test database.
The test that catches bugs creates data for two tenants and asserts that each tenant only sees their own. Write this test for every tenant-scoped model. It takes five minutes per model and catches the bugs that cost days to diagnose in production.
Test coverage beyond HTTP requests
For applications handling sensitive data (financial, medical, legal), add fuzz testing: randomly switch tenant context during a test run and assert that no cross-tenant data appears. This is the kind of testing that feels excessive until it catches a bug that would have been a data breach.
Production Concerns Tutorials Skip
Tutorials stop at "it works in development." Production multi-tenancy introduces constraints that only appear under real load with real tenants.
The noisy neighbour problem
One tenant's bulk import should not slow down every other tenant's dashboard. Without isolation, a tenant running a 50,000-row CSV import consumes all queue workers and database connections.
Indexed tenant columns
Add a composite index on (tenant_id, created_at) (or whatever your common query pattern is) to every scoped table. A missing index on a table with 2 million rows across 500 tenants turns a 5ms query into a 3-second table scan.
Dedicated queues for heavy operations
Route bulk imports to a separate queue with rate limiting. Keep the default queue responsive for interactive operations.
Per-tenant rate limiting
Apply rate limits at the tenant level, not just the user level. A single tenant with 50 users can overwhelm an API endpoint that rate-limits per user at 60/minute.
Cache key prefixing
Every cache key must include the tenant identifier. Without this, cache('dashboard_stats') returns the same value for every tenant. We wrap this in a helper: TenantCache::get('user_count') automatically prepends the tenant prefix. Forgetting the prefix is easy. Making it automatic is cheap insurance.
File storage isolation
Uploaded files must be stored under a tenant-scoped path. Without this, a tenant who guesses a file path can access another tenant's documents. Combine tenant-prefixed paths with a storage policy that validates the tenant prefix on every file access. Do not rely on obscurity.
Tutorial versus production
| Concern | Tutorial Approach | Production Pattern |
|---|---|---|
| Tenant scoping | Manual WHERE tenant_id = ? |
Eloquent global scope + BelongsToTenant trait |
| Queue isolation | No tenant context in jobs | TenantAwareJob base class |
| Cache keys | Global keys | Tenant-prefixed via TenantCache helper |
| File storage | Flat directory | Tenant-scoped paths with access validation |
| Testing | Single-tenant tests | Two-tenant isolation tests for every scoped model |
| Scheduled tasks | Fail-fast (one tenant crashes all) | Catch per tenant, report, continue |
Observability
When an error occurs in a multi-tenant application, the first question is always "which tenant?" Every log entry, exception report, and queue job trace should include the tenant identifier. In Laravel, this means adding tenant_id to the logging context early in the middleware and ensuring it propagates through to exception handlers, Horizon's job metadata, and any external monitoring tools.
For applications with admin or support access across tenants, log every context switch. An audit trail of "support user X viewed tenant Y's data at timestamp Z" is not optional for applications handling sensitive information. The audit log itself must be stored outside tenant scope.
Tenant Provisioning Lifecycle
New tenant creation involves more than inserting a database row. A production provisioning flow follows a defined sequence, and each step must be idempotent because provisioning will fail partway through.
Create tenant record
Insert with provisioning status. Seed default data: roles, permissions, settings, notification templates.
Provision external resources
Stripe customer, S3 bucket prefix, mail domain verification. Run tenant-specific database migrations if using per-tenant databases.
Activate and notify
Update status to active. Send welcome notification. Tenant lifecycle states matter: provisioning, trial, active, suspended, cancelled.
A suspended tenant should not be able to log in but their data must be preserved. A cancelled tenant enters a grace period before data deletion. These are business rules that belong in the data model, not in ad-hoc checks scattered across controllers.
When Multi-Tenancy Is Not the Right Choice
Not every application that serves multiple clients needs multi-tenancy. If each client has fundamentally different business rules, different data models, or different compliance requirements, separate applications may be simpler and cheaper to maintain. Multi-tenancy saves operational cost when tenants share 90%+ of the codebase. Below that threshold, the scoping complexity outweighs the deployment savings.
Similarly, if the tenant count will stay below five, the overhead of tenant identification, global scopes, and queue isolation may not justify itself. Five separate deployments, each with their own database, can be simpler to reason about than one multi-tenant application with five tenants.
The decision is architectural. Once made, it is expensive to reverse. If you are building a SaaS product that will serve dozens or hundreds of clients with the same core features, multi-tenancy is almost certainly the right call. If you are building custom web applications for a handful of enterprise clients with divergent requirements, think carefully before committing.
Multi-Tenant Architecture Review Checklist
Before shipping a multi-tenant Laravel application to production, walk through this checklist. Each item represents a failure mode we have encountered across our projects.
BelongsToTenant trait. Global and central models are explicitly marked as such.tenant_id. Check database indexes, not just validation rules.Rule::unique, Rule::exists) scope by tenant on tenant-scoped tables.tenant_id.DB::table() queries against tenant-scoped tables include a tenant filter.This is not exhaustive, but it covers the 10 most common failure points. If all 10 pass, the architecture is in good shape. If any fail, fix them before launch.
Get your multi-tenant architecture reviewed
Whether you are planning a new multi-tenant Laravel application or inheriting one that needs attention, an architecture review catches isolation gaps before your customers do. We will walk through your tenant scoping, queue handling, and data model, and tell you honestly what needs fixing.
Request an architecture review →