Multi-Tenant Laravel

Architecture for Serving Multiple Clients from One Codebase

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.

The patterns that follow are the architectural decisions, failure modes, and production concerns that tutorials consistently skip. They are drawn from building multi-tenant applications across a range of projects and industries.

Tenant isolation schematic
https://acme.app.com/dashboard
Middleware
// 1. Resolve Tenant from Subdomain
$tenant = Tenant::findByDomain('acme');
// 2. Bind to Global Scope
Context::set('current_tenant_id', 101);
Generated SQL
SELECT * FROM orders
WHERE deleted_at IS NULL
AND tenant_id = 101
ORDER BY created_at DESC;
🔒 Scope: TenantScope Applied
Shared database table (schematic view)

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.

TL;DR: Shared database with a tenant_id column suits most SaaS applications. Simpler operations, cross-tenant analytics are straightforward, scales to thousands of tenants. Switch to database-per-tenant when compliance, per-tenant backup/restore, or extreme data volume variance forces it.

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, the practical limit is typically in the tens to low hundreds of 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 (NHS DSPT, FCA, or contractual obligations that specify physical isolation). It also makes sense 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 the majority of use cases. The remainder typically involve healthcare, financial services, or government contracts where physical separation is a compliance requirement.

The database strategy is not a permanent decision. Teams typically start with shared-database because it is simpler, then migrate when a specific trigger forces the change. Common triggers: a compliance audit requires physical separation, a large tenant's query volume degrades shared indexes, a customer contractually requires independent backup and restore, or noisy-neighbour incidents become frequent enough to justify dedicated resources. Knowing these triggers in advance means you can build the shared-database version without painting yourself into a corner.

The infrastructure implications differ significantly between strategies. Shared-database deployments work well on a single server or managed database service, following twelve-factor app principles for configuration. Database-per-tenant deployments need connection pooling via PgBouncer, automated database provisioning, and orchestrated migration runs across every database. Tools like Laravel Forge handle the single-server case well; database-per-tenant at scale typically requires custom automation or a platform like Laravel Vapor with per-tenant database provisioning scripts.


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.

Switching costs are real. Migrating between packages mid-project is more expensive the more deeply the package's conventions are wired into the codebase: tenant identification, queue handling, and database connection management all need rewriting. Migrating from either package to a custom implementation is generally easier than the reverse, because you are removing abstraction rather than adding it. Factor the switching cost into the initial decision, especially for applications expected to run for years.


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. The choice affects URL structure, DNS configuration, SSL certificate management, and how the middleware resolves tenant context. Most SaaS applications start with subdomain identification. Custom domains come later if white-labelling is required.

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 and customer portals. 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. We diagnosed exactly this on a project running Octane. The tenant context was stored in a singleton that survived between requests. When the first request failed, the singleton retained Tenant A's state, and the second request (for Tenant B) inherited it. The bug was invisible in standard testing because each test request starts with a fresh application instance.

// 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, omit the SESSION_DOMAIN configuration entirely (or set it to null) so that Laravel issues a host-only cookie scoped to the exact subdomain of the request. Setting it to a wildcard like .yourapp.com means a session created on one tenant's subdomain is valid on every other tenant's subdomain. The Production Concerns section below covers session, Sanctum, and rate-limit isolation in detail.


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; }

Control plane vs tenant plane: Not every model belongs to a tenant. System configuration, plan definitions, feature flags, country lists, and other reference data form the control plane and must remain globally accessible. Tenant-scoped data (orders, users, invoices, uploads) forms the tenant plane. If you think in domain-driven design terms, each tenant effectively operates within its own bounded context, while the control plane spans all contexts. Mark control plane models clearly. We use a GlobalModel trait (which does nothing functionally, but acts as documentation) to signal that a model is intentionally unscoped. When adding a new model, classify it explicitly before writing the first migration.

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. The most recent: a reporting query that joined orders to invoices applied the tenant scope to orders but not to the join condition on invoices. The result set included invoice rows from other tenants. A customer noticed someone else's company name in a downloaded report.

PostgreSQL Row Level Security: defence in depth

The raw query gap is the strongest argument for adding a second isolation layer at the database level. PostgreSQL Row Level Security (RLS) enforces tenant filtering in the database engine itself. If a raw query, a DB::table() call, or a third-party package bypasses the Eloquent scope, the database blocks the cross-tenant access.

The pattern: set a session variable at the start of each database connection, then create RLS policies that filter rows based on that variable. In Laravel, add a SET statement in the tenant middleware after resolving the tenant.

-- Enable RLS on a tenant-scoped table
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;

-- Create a policy that filters by tenant_id
CREATE POLICY tenant_isolation ON orders
  USING (tenant_id = current_setting('app.current_tenant_id')::int);

-- Force the policy on the table owner too
ALTER TABLE orders FORCE ROW LEVEL SECURITY;

RLS is not a replacement for global scopes. It is a safety net. Global scopes remain the primary mechanism because they integrate with Eloquent's query builder, relationships, and eager loading. RLS catches the cases that global scopes cannot. The performance overhead is negligible for queries that already filter by tenant_id (the query planner recognises the equivalence). However, RLS does add complexity to migrations, seeders, Tinker sessions, and pooled connections. PgBouncer in transaction mode resets session variables between transactions, so the SET must run per-transaction, not per-connection. Central contexts that intentionally bypass tenant scoping must run as a database role exempt from RLS policies.

We use RLS on projects handling financial or medical data where the cost of a data leakage incident justifies the additional complexity. For applications where the risk profile is lower, well-tested global scopes with a raw-query audit are sufficient.

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. Each of the following has caused a data leakage bug on at least one project we have worked on. They are easy to miss because they do not look like database queries.

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 readonly int $tenantId;

    public function __construct()
    {
        $this->tenantId = TenantContext::requireId();
    }

    public function handle(): void
    {
        $tenant = Tenant::findOrFail($this->tenantId);
        TenantContext::set($tenant);

        try {
            // Resolve via the container so child classes get
            // dependency injection on execute(), the same way
            // Laravel injects dependencies on handle().
            app()->call([$this, 'execute']);
        } finally {
            TenantContext::clear();
        }
    }

    // Child classes implement execute() with type-hinted
    // dependencies. The container resolves them automatically.
    abstract protected function execute(): void;
}

// Usage:
// class ProcessInvoice extends TenantAwareJob
// {
//     protected function execute(InvoiceService $invoices): void
//     {
//         $invoices->generateForTenant($this->tenantId);
//     }
// }

// 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.

Child classes that override the constructor must call parent::__construct() or the tenant ID will not be captured. On larger teams, consider using a job middleware instead of a base class, which avoids the constructor convention entirely: the middleware restores tenant context from a property that can be set via a trait, without requiring inheritance.

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.

Horizon, Octane, and Reverb interactions

Three Laravel subsystems interact with multi-tenancy in ways that are poorly documented and consistently cause production bugs.

Horizon supervises queue workers and provides auto-balancing across queues. Tenant context must be visible in Horizon's job metadata for debugging, which means adding tenant_id to the job's tags. Horizon's auto-balancing does not account for tenant affinity, so a tenant with a spike in jobs can consume all workers on a queue. For applications with uneven workloads, configure dedicated queues per operation type (imports, notifications, reports) rather than per tenant.

Octane runs Laravel in a long-lived process. Any state stored in static properties, singletons, or service container bindings survives between requests. If the tenant context is set but not cleaned up (because the finally block did not run, or because context was stored in a singleton that Octane does not flush), the next request inherits the previous tenant's state. The fix is structural: store tenant context in a location that Octane's request lifecycle resets automatically, or register a RequestTerminated listener that clears it. We test for this explicitly: dispatch two sequential requests for different tenants, force the first to fail, and assert the second resolves its own tenant independently.

Reverb handles WebSocket broadcasting, commonly used for real-time dashboards and live notifications. Channel authorisation callbacks must verify that the authenticated user belongs to the tenant associated with the channel. Without this check, a user who discovers a channel name pattern can subscribe to another tenant's events. Prefix private channel names with the tenant identifier: private-tenant.{tenantId}.orders.


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

HTTP request tests are the obvious starting point, but most multi-tenancy bugs occur outside the request lifecycle. Each of the following contexts needs its own isolation test. The pattern is the same in each case: create data for two tenants, execute the operation in one tenant's context, and assert the other tenant's data is invisible.

Queued jobs: Dispatch as tenant A, assert job only processes tenant A data.
Artisan commands: Run for tenant A, verify no tenant B side effects.
Scheduled tasks: Run the iterator, verify per-tenant isolation.
Notification channels: Tenant A's notification does not reference tenant B's data.

Concrete isolation test patterns

The two-tenant test pattern is simple enough to show inline. These examples use Pest, but the same logic works in PHPUnit. The first test verifies Eloquent model scoping. The second catches the raw query gap that global scopes cannot protect against.

// Test: Eloquent model isolation
it('prevents tenant A from seeing tenant B orders', function () {
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();

    TenantContext::set($tenantA);
    $orderA = Order::factory()->create();

    TenantContext::set($tenantB);
    $orderB = Order::factory()->create();

    // Tenant B should only see their own order
    expect(Order::pluck('id')->all())
        ->toContain($orderB->id)
        ->not->toContain($orderA->id);
});

// Test: Cache key isolation
it('isolates cache keys between tenants', function () {
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();

    TenantContext::set($tenantA);
    TenantCache::put('user_count', 42);

    TenantContext::set($tenantB);
    TenantCache::put('user_count', 7);

    // Switch back to tenant A; value must be 42, not 7
    TenantContext::set($tenantA);
    expect(TenantCache::get('user_count'))->toBe(42);
});

// Test: Reporting query isolation (catches join leakage)
it('scopes reporting queries that join across tables', function () {
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();

    TenantContext::set($tenantA);
    $orderA = Order::factory()->has(Invoice::factory())->create();

    TenantContext::set($tenantB);
    $orderB = Order::factory()->has(Invoice::factory())->create();

    // A reporting query that joins orders to invoices must
    // return only tenant B's data in tenant B's context.
    $results = DB::table('orders')
        ->join('invoices', 'invoices.order_id', '=', 'orders.id')
        ->where('orders.tenant_id', TenantContext::id())
        ->where('invoices.tenant_id', TenantContext::id())
        ->select('orders.id')
        ->get();

    expect($results)->toHaveCount(1);
    expect($results->first()->id)->toBe($orderB->id);
});

Write the Eloquent isolation test for every tenant-scoped model. It takes five minutes per model. The cache test only needs writing once. The reporting-query example above demonstrates the pattern for testing join leakage; in production, point it at your actual reporting queries or repository methods rather than inline SQL so that it catches regressions when the query changes. If the application also uses PostgreSQL RLS, these tests verify both layers simultaneously.

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. The problems compound as tenant count grows, and scaling without chaos requires anticipating each threshold.

At 50 tenants, missing composite indexes on tenant-scoped tables surface as slow queries. At 200 tenants, connection pooling and migration orchestration become painful, and PgBouncer's pool sizing needs attention. At 500+ tenants, query planner skew from uneven data distribution across tenants, Horizon worker allocation, per-tenant backup extraction, and the need for tenant_id in every log line all become critical operational concerns.

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.

We have debugged a production incident where two tenants saw identical dashboard statistics for three days. The cause: a developer used Cache::get('monthly_revenue') without the tenant prefix. The fix took five minutes. Finding it took two days, because the symptoms (correct-looking but wrong numbers) did not trigger any errors. After that incident, we added a static analysis rule that flags any direct Cache:: call in tenant-scoped code.

The same principle extends to cache-adjacent systems. Redis locks must include the tenant prefix, or one tenant's lock blocks every other tenant. If the application uses Laravel Scout with Meilisearch or Algolia, search indexes must be scoped per tenant (either separate indexes or a filterable tenant_id attribute). Cache tags, if used, need tenant namespacing to prevent tag-based flushes from clearing another tenant's cached data.

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. For files served to the browser, use signed URLs with expiration rather than public paths. Do not rely on obscurity.

Session, auth, and rate-limit isolation

Three isolation surfaces that even experienced developers miss. Each is as much a security concern as a data isolation concern.

Session cookies: When using subdomains, omit the SESSION_DOMAIN configuration (or set it to null) so Laravel issues a host-only cookie. This scopes the cookie to the exact subdomain that issued it. Setting a wildcard domain (.yourapp.com) means a session created on one tenant's subdomain is valid on every other tenant's subdomain. Even with host-only cookies, verify on every authenticated request that the logged-in user belongs to the current tenant. A valid session combined with a different subdomain must not grant access.

Sanctum tokens: If the application uses Laravel Sanctum for API authentication, personal access tokens must be scoped to the tenant. A token issued to a user in tenant A should not authenticate requests to tenant B's subdomain. Either store tenant_id on the token itself, or validate the token's user against the current tenant context on every request.

CSRF tokens: CSRF tokens are bound to the session. If sessions leak across tenants (because of a wildcard session domain), CSRF tokens travel with them. A valid CSRF token from tenant A's subdomain should not be accepted on tenant B's subdomain. Host-only session cookies prevent this, but verify it explicitly in your test suite.

Rate-limit keys: Laravel's built-in rate limiter keys by IP or user ID by default. In a multi-tenant application, rate limits should include the tenant identifier. Without this, a tenant's rate limit consumption is shared across all tenants if they happen to share an IP (common with corporate proxies), or a single tenant with many users can exhaust per-user limits that were designed assuming single-tenant usage.

Tutorial versus production

The gap between tutorial-grade and production-grade multi-tenancy is larger than most developers expect. Tutorials get you to "it works on my machine" quickly, but each of the following concerns will surface within the first few months of real usage. This table summarises the patterns covered in the sections above.

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.

Named failure modes: a quick reference

Every multi-tenancy bug we have seen falls into one of these categories. Name them in code reviews and incident reports so the team builds a shared vocabulary.

  • Scope leakage via join: A query joins two tables but only filters one by tenant_id. Result rows include another tenant's data.
  • Raw query bypass: A DB::table() or raw SQL query skips the Eloquent global scope entirely.
  • Job context loss: A queued job runs without tenant context because it does not extend TenantAwareJob or equivalent.
  • Cache key collision: Two tenants share a cache key (monthly_revenue) because the key lacks a tenant prefix.
  • Session cookie bleed: A wildcard session domain means a session from one tenant's subdomain is valid on every other.
  • File path traversal: A tenant guesses another tenant's file path because storage is not tenant-prefixed or validated.
  • Validation cross-contamination: Rule::unique or Rule::exists checks against the full table instead of scoping by tenant.
  • Stale Octane context: A long-lived worker retains the previous request's tenant state after a failure.
  • Rate-limit key sharing: Tenants behind the same corporate proxy share rate-limit counters because keys are IP-based.
  • Broadcasting channel leakage: A user subscribes to another tenant's channel because authorisation does not verify tenant membership.
  • Route model binding leakage: Implicit model binding resolves a record without checking the tenant scope, allowing URL manipulation.

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.

1

Create tenant record

Insert with provisioning status. Run tenant seeding to populate default data: roles, permissions, settings, notification templates.

2

Provision external resources

Stripe customer, S3 bucket prefix, mail domain verification. Run tenant-specific database migrations if using per-tenant databases.

3

Activate and notify

Update status to active. Send welcome notification. Tenant lifecycle states matter: provisioning, trial, active, suspended, cancelled.

Each step must be idempotent. Provisioning will fail partway through: the Stripe API times out after the database row is created, the DNS verification fails after the Stripe customer exists. The provisioning pipeline should be safe to re-run from any point without duplicating resources. Use status flags on the tenant record to track which steps have completed. For complex provisioning flows with conditional branches (enterprise tenants needing dedicated databases, trial tenants getting sandbox data), a workflow engine pattern keeps the orchestration maintainable.

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. A purged tenant's data is permanently removed (with GDPR-compliant audit logging of the deletion). These are business rules that belong in the data model as a state machine, not in ad-hoc checks scattered across controllers. The full lifecycle: provisioning > trial > active > suspended > cancelled > purged. Each transition should fire domain events so that dependent systems (billing, notifications, DNS) react without tight coupling.


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: a custom CRM serving multiple agencies, a project management tool for different teams, or a booking platform for independent operators. 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.

Migrating from single-tenant to multi-tenant is a common real-world scenario, and it is harder than starting fresh. The work involves adding tenant_id columns to every relevant table (using zero-downtime migration patterns on large tables), backfilling existing data, retrofitting global scopes, auditing every raw query and validation rule, scoping cache keys and file paths, and converting jobs. The legacy migration principles apply. Test with production-scale data, have a rollback plan, and never assume the first pass catches everything.


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. They are grouped by the layer they protect.

Data layer

These items ensure that tenant data cannot leak through queries, constraints, or raw SQL. Every item below has caused a real data leakage incident on at least one project we have worked on.

Every tenant-scoped Eloquent model uses the BelongsToTenant trait. Global and central models are explicitly marked as such.
Unique constraints on tenant-scoped tables include tenant_id. Check database indexes, not just validation rules.
Validation rules (Rule::unique, Rule::exists) scope by tenant on tenant-scoped tables.
All raw SQL and DB::table() queries against tenant-scoped tables include a tenant filter.

Async and infrastructure

Background jobs, scheduled commands, and shared infrastructure (cache, files) all operate outside the HTTP request lifecycle and need explicit tenant scoping. These are the contexts where tenant isolation bugs are hardest to detect, because they produce no user-visible error.

Every queued job is explicitly classified as tenant-aware or central. Tenant-aware jobs carry and restore context.
Cache keys are tenant-prefixed. Test by creating the same cache key for two tenants.
File storage paths are tenant-scoped with access validation on retrieval.
Scheduled commands iterate tenants with per-tenant error handling.

Auth and access control

Session cookies, API tokens, rate limits, and real-time channels all create paths for cross-tenant access if not scoped correctly. These are often the last items checked because they sit outside the database layer, but a session cookie leak is just as much a data breach as a missing WHERE clause.

Session cookies use host-only scoping (no wildcard domain). Authenticated requests verify user-tenant membership.
API tokens (Sanctum) include tenant context. A token issued for tenant A does not authenticate requests to tenant B.
Rate-limit keys include the tenant identifier. Per-user limits alone do not prevent a single tenant from consuming shared capacity.
Broadcasting channel names include the tenant identifier and authorisation callbacks verify tenant membership.

Observability and testing

Without tenant context in logs and two-tenant isolation tests, bugs are invisible until a customer reports them. Observability is what turns "we think it is isolated" into "we know it is isolated."

Logs, exceptions, and queue traces include tenant_id.
Two-tenant isolation tests exist for every scoped model, job, and command.

This covers the 14 most common failure points across four layers. If all 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. If you are starting from scratch, our custom build service handles multi-tenant architecture from the ground up.

Discuss your multi-tenant architecture →
Graphic Swish