Multi-Tenant Laravel

Architecture for Serving Multiple Clients from One Codebase


Multi-tenancy lets a single application serve multiple clients, each with their own data, configuration, and sometimes branding. All from one codebase. It's how SaaS products work: many customers, one system. The architecture aligns with scaling without chaos: growing your customer base without proportionally growing operational complexity.

Done right, multi-tenancy enables efficient scaling and simplified maintenance. Done wrong, it creates data leakage risks and architectural nightmares. The approach you choose has lasting implications for security, performance, and operational complexity.

This page covers the architectural decisions, implementation patterns, and security considerations for building multi-tenant Laravel applications. The focus is on shared-database multi-tenancy (tenant column approach), which suits most B2B SaaS applications serving small-to-medium businesses.

Multi-Tenant Isolation Chamber
🔒 https://acme.app.com/dashboard
Acme Corp
Globex Inc
Stark Ind
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
Physical Database Table (Shared Iron)

The Core Constraint

The fundamental challenge of multi-tenancy isn't technical complexity. It's preventing data leakage while maintaining development velocity. Every query, every cache key, every background job, every file upload must be scoped to the correct tenant. Miss one, and you've created a security incident.

The naive approach is to add WHERE tenant_id = ? to every query manually. This works until someone forgets. In a codebase with hundreds of queries across dozens of models, the probability of missing a scope approaches certainty. And the failure mode is silent: the wrong data gets returned, the wrong records get updated, and you don't know until a customer notices they're seeing someone else's orders.

The failure mode matters: Tenant isolation failures are silent. Unlike a 500 error or a validation failure, data leakage doesn't announce itself. The application continues working; it just returns the wrong data. This makes testing and architectural rigour essential.


Multi-Tenancy Approaches

Three patterns dominate. Each trades off isolation, operational complexity, and development velocity differently.

Database per tenant

Each tenant gets their own database. Complete isolation at the infrastructure level.

Pros: Complete data isolation, easy backup per tenant, simple queries, compliance-friendly

Cons: More databases to manage, schema changes must apply to all, cross-tenant reporting is complex, connection pool exhaustion at scale

Schema per tenant

Shared database, but each tenant has their own schema (table namespace). PostgreSQL handles this well; MySQL less so.

Pros: Good isolation, single database to manage, per-tenant backup possible

Cons: Schema management complexity, migration tooling gaps, not portable across database engines

Shared database, tenant column

All tenants share tables. A tenant_id column identifies which rows belong to which tenant.

Pros: Simpler infrastructure, easy schema changes, cross-tenant analytics possible, efficient resource use

Cons: Every query must filter by tenant, risk of data leakage if filtering missed, noisy neighbour potential

Choosing an approach

Database per tenant: When compliance requires complete isolation (healthcare, finance), when tenant data sizes vary by orders of magnitude, or when tenants need independent backup/restore capabilities. Shared database: When you have many small tenants, need cross-tenant features, or want simpler operations. For most Laravel applications serving small-to-medium businesses, shared database with tenant column is the pragmatic choice. It's what we use for 80% of our multi-tenant builds.

The rest of this page focuses on the shared-database approach. The principles apply to other approaches, but the implementation details differ.


Tenant Identification Strategies

Before you can scope queries, you need to know which tenant the request is for. Four strategies dominate, each with different tradeoffs for user experience, infrastructure complexity, and flexibility.

Subdomain identification

Each tenant gets a subdomain: acme.yourapp.com, globex.yourapp.com. The application parses the subdomain from the request and resolves it to a tenant record.

Implementation: Middleware extracts the subdomain from $request->getHost(), looks up the tenant by subdomain column, and sets the current tenant context. Wildcard DNS (*.yourapp.com) points to your application; no per-tenant DNS configuration needed.

Tradeoffs: Clean URLs, simple to implement, works well with wildcard SSL certificates. Slightly more complex local development setup (need to configure /etc/hosts or use a tool like Laravel Valet). Cookie scope requires attention: set to .yourapp.com for cross-subdomain sessions, or per-subdomain for isolation.

Best for: Most B2B SaaS applications. The standard choice.

Path-based identification

Tenant identifier appears in the URL path: yourapp.com/acme/dashboard, yourapp.com/globex/orders. A route parameter captures the tenant slug.

Implementation: Route prefix captures the tenant: Route::prefix('{tenant}')->group(...). Route model binding or middleware resolves the slug to a tenant record. All tenant routes must include the prefix; absolute URLs need careful generation.

Tradeoffs: Single domain simplifies SSL and DNS. But every route needs the prefix, URL generation is more complex, and the tenant slug appears in every link. Easier for users to accidentally modify the URL and access the wrong tenant (though scopes should prevent data access).

Best for: Applications where subdomain setup isn't feasible, or where tenants are more like "workspaces" within a single user's account.

Custom domain mapping

Tenants bring their own domains: orders.acme.com, app.globex.co.uk. The application maintains a mapping from domain to tenant.

Implementation: Tenants configure a CNAME record pointing their domain to your application. Middleware looks up the incoming domain against a domains table linked to tenants. SSL certificates can be provisioned via Let's Encrypt with DNS-01 or HTTP-01 challenges.

Tradeoffs: Premium white-label experience. But significant infrastructure complexity: wildcard SSL won't work, each domain needs its own certificate, you need automated certificate provisioning and renewal, and DNS verification during onboarding adds friction.

Best for: White-label products where the tenant's brand identity is paramount. Usually offered as a premium feature alongside subdomain access.

Header-based identification

For API-only applications, a header carries the tenant identifier: X-Tenant-ID: acme or a tenant claim in the JWT.

Implementation: Middleware extracts the tenant from the header or token payload. For JWTs, the tenant identifier is typically a claim set during token generation.

Tradeoffs: Clean separation between authentication (who) and tenancy (which workspace). Works well for API-first architectures. Not applicable for browser-based applications without a subdomain or path strategy for initial authentication.

Best for: API-first products, mobile backends, or machine-to-machine integrations.

Most applications start with subdomain identification and add custom domain support later if customers demand it. Start simple; add complexity when it's paid for.


Setting Tenant Context

Once you've identified the tenant from the request, you need to make that context available throughout the request lifecycle. The pattern is straightforward: resolve early in the middleware stack, store in a singleton, access everywhere.

The Tenant Context Singleton

A simple class holds the current tenant and provides static access throughout the application:

class TenantContext
{
    private static ?Tenant $current = null;

    public static function set(Tenant $tenant): void
    {
        static::$current = $tenant;
    }

    public static function get(): ?Tenant
    {
        return static::$current;
    }

    public static function id(): ?int
    {
        return static::$current?->id;
    }

    public static function reset(): void
    {
        static::$current = null;
    }
}

For applications using Laravel's container more heavily, you can bind the tenant as a scoped singleton instead of using static state. The static approach is simpler for most use cases and explicit about its global nature.

Tenant Resolution Middleware

Middleware runs early in the stack to resolve and set the tenant context:

class ResolveTenant
{
    public function handle(Request $request, Closure $next)
    {
        $subdomain = explode('.', $request->getHost())[0];

        $tenant = Tenant::where('subdomain', $subdomain)->first();

        if (! $tenant) {
            abort(404, 'Tenant not found');
        }

        if (! $tenant->is_active) {
            abort(403, 'Account suspended');
        }

        TenantContext::set($tenant);

        return $next($request);
    }

    public function terminate(Request $request, $response)
    {
        TenantContext::reset();
    }
}

Key points: fail fast if the tenant doesn't exist or is inactive. Check account status here rather than scattering status checks throughout the application. Reset context in terminate() to prevent state leakage in long-running processes (Octane, queue workers with --once).

Caching Tenant Lookups

Tenant resolution happens on every request. For high-traffic applications, cache the lookup:

$tenant = Cache::remember(
    "tenant:subdomain:{$subdomain}",
    now()->addMinutes(5),
    fn () => Tenant::where('subdomain', $subdomain)->first()
);

// Bust cache when tenant is updated
Tenant::updated(function (Tenant $tenant) {
    Cache::forget("tenant:subdomain:{$tenant->subdomain}");
});

Keep the TTL short (5-10 minutes). Tenant records change infrequently, but when they do (suspension, plan change, subdomain rename), you want changes to propagate quickly.


Database Isolation with Global Scopes

This is where multi-tenancy succeeds or fails. Every tenant-scoped model needs to automatically filter queries by the current tenant and automatically set tenant_id on creation. Eloquent global scopes make this automatic.

The Tenant Scope

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (TenantContext::id()) {
            $builder->where($model->getTable() . '.tenant_id', TenantContext::id());
        }
    }
}

Note the table prefix on the column name. Without it, queries with joins will fail with ambiguous column errors. This is an easy thing to miss in early development and catch in production when someone adds a join.

The BelongsToTenant Trait

A trait applies the scope and handles automatic tenant assignment:

trait BelongsToTenant
{
    public static function bootBelongsToTenant(): void
    {
        static::addGlobalScope(new TenantScope());

        static::creating(function (Model $model) {
            if (! $model->tenant_id && TenantContext::id()) {
                $model->tenant_id = TenantContext::id();
            }
        });
    }

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }
}

Apply this trait to every tenant-scoped model. The creating callback ensures new records are assigned to the current tenant automatically. The guard (if (! $model->tenant_id)) allows explicit assignment when needed (seeding, admin operations).

Which Models Get Scoped

Not every model belongs to a tenant. A typical split:

Scoped to tenant Global (no scope)
Users (tenant's users) Tenant (the tenant record itself)
Orders, Invoices, Products Plans, Features (subscription tiers)
Projects, Tasks, Comments Countries, Currencies, Timezones
Uploaded files, Documents Admin users, System settings
Audit logs (tenant's activity) Global audit logs (admin activity)

The rule: if the data belongs to a customer, it gets scoped. If it's reference data or system configuration, it doesn't.

Bypassing Tenant Scope

Sometimes you need to query across tenants: admin dashboards, cross-tenant reporting, scheduled tasks that process all tenants. Use withoutGlobalScope() explicitly:

// Admin: count orders across all tenants
$totalOrders = Order::withoutGlobalScope(TenantScope::class)->count();

// Process each tenant in a scheduled command
Tenant::each(function (Tenant $tenant) {
    TenantContext::set($tenant);

    // All queries now scoped to this tenant
    $this->processOrders($tenant);

    TenantContext::reset();
});

Audit cross-tenant access: Any code that bypasses tenant scope should be logged. This creates an audit trail for compliance and makes it easier to review all cross-tenant access points during security audits.


Queued Jobs and Background Processing

Background jobs run outside the HTTP request lifecycle. There's no middleware to resolve the tenant, no request context to read. Jobs must carry their tenant context explicitly. This extends the patterns we cover in background jobs, with tenant awareness as an additional requirement.

The Naive Approach Fails

The temptation is to assume TenantContext::get() will work in jobs. It won't. By the time the job executes (possibly minutes or hours later, possibly on a different server), the original request context is gone. Every query in the job runs without tenant scope, potentially accessing or modifying data across all tenants.

The Robust Pattern: Tenant-Aware Jobs

Serialize the tenant ID with the job and restore context before execution:

trait TenantAwareJob
{
    public int $tenantId;

    public function initializeTenantAwareJob(): void
    {
        if (TenantContext::id()) {
            $this->tenantId = TenantContext::id();
        }
    }

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

    public function tearDownTenantContext(): void
    {
        TenantContext::reset();
    }
}

In your job class:

class ProcessOrderJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    use TenantAwareJob;

    public function __construct(public Order $order)
    {
        $this->initializeTenantAwareJob();
    }

    public function handle(): void
    {
        $this->setUpTenantContext();

        try {
            // All queries now scoped to the correct tenant
            $this->order->process();
        } finally {
            $this->tearDownTenantContext();
        }
    }
}

The finally block ensures context is reset even if the job fails, preventing state leakage when queue workers process multiple jobs.

Tenant-Specific Queues

For noisy neighbour isolation, route large tenants to dedicated queues:

class ProcessOrderJob implements ShouldQueue
{
    public function queue(): string
    {
        $tenant = Tenant::find($this->tenantId);

        return $tenant->dedicated_queue ?? 'default';
    }
}

Run separate workers for high-volume tenants. Their job backlogs won't affect other tenants' processing times.


Scheduled Tasks Across Tenants

Scheduled commands (daily reports, cleanup jobs, notification batches) often need to run for all tenants. The pattern: iterate tenants, set context, execute, reset.

class SendDailyReports extends Command
{
    protected $signature = 'reports:daily';

    public function handle(): void
    {
        Tenant::where('is_active', true)
            ->cursor()
            ->each(function (Tenant $tenant) {
                TenantContext::set($tenant);

                try {
                    $this->sendReportForTenant($tenant);
                } catch (Throwable $e) {
                    // Log but don't stop - other tenants still need processing
                    Log::error("Daily report failed for tenant {$tenant->id}", [
                        'exception' => $e->getMessage(),
                    ]);
                } finally {
                    TenantContext::reset();
                }
            });
    }

    private function sendReportForTenant(Tenant $tenant): void
    {
        // All queries automatically scoped to current tenant
        $orders = Order::whereDate('created_at', today())->get();
        // ...
    }
}

Key points: use cursor() for memory efficiency with many tenants. Catch exceptions per-tenant so one failure doesn't stop the batch. Log failures with tenant context for debugging.

Parallelising Tenant Processing

For computationally intensive tasks, dispatch a job per tenant instead of processing sequentially:

class DispatchDailyReports extends Command
{
    public function handle(): void
    {
        Tenant::where('is_active', true)
            ->pluck('id')
            ->each(fn ($id) => GenerateTenantReportJob::dispatch($id));
    }
}

Now each tenant's report generates in parallel across your queue workers. Add rate limiting if the downstream service (email provider, PDF generator) can't handle the burst.


Cross-Tenant Queries and Reporting

Admin dashboards and platform-level analytics need to query across tenants. This is where shared-database multi-tenancy shines: cross-tenant queries are just SQL, not a distributed data aggregation problem.

Platform Metrics

// Admin dashboard: aggregate metrics
$metrics = DB::table('orders')
    ->selectRaw('tenant_id, COUNT(*) as order_count, SUM(total) as revenue')
    ->whereDate('created_at', '>=', now()->subDays(30))
    ->groupBy('tenant_id')
    ->get();

// Join to get tenant names
$report = DB::table('orders')
    ->join('tenants', 'orders.tenant_id', '=', 'tenants.id')
    ->selectRaw('tenants.name, COUNT(orders.id) as orders, SUM(orders.total) as revenue')
    ->groupBy('tenants.id', 'tenants.name')
    ->orderByDesc('revenue')
    ->get();

These queries bypass Eloquent (and thus global scopes) intentionally. For platform-level reporting, raw queries are often clearer and more performant than Eloquent with withoutGlobalScope() scattered throughout.

Tenant Comparison Reports

SaaS platforms often want to show tenants how they compare to benchmarks:

// "Your conversion rate vs. platform average"
$platformAverage = Order::withoutGlobalScope(TenantScope::class)
    ->where('status', 'completed')
    ->count() / Order::withoutGlobalScope(TenantScope::class)->count();

$tenantRate = Order::where('status', 'completed')->count()
    / Order::count();

Anonymise carefully. Tenants shouldn't be able to identify competitors' specific metrics. Show percentiles, not raw numbers from other accounts.

Data Export for Business Intelligence

For platforms that feed data into external BI tools, consider a read replica with tenant scoping applied at the database level (row-level security in PostgreSQL) rather than relying on application-level scopes. This prevents BI tools with direct database access from accidentally querying across tenants.


Tenant Provisioning and Lifecycle

New tenants need more than a database record. Provisioning typically includes creating the tenant record, seeding default data, provisioning external resources, and notifying the tenant.

Provisioning Workflow

class ProvisionTenantJob implements ShouldQueue
{
    public function __construct(public Tenant $tenant) {}

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

        try {
            // Seed default data
            $this->createDefaultRoles();
            $this->createDefaultSettings();
            $this->createSampleData(); // Optional: demo data for trials

            // Provision external resources
            $this->createStripeCustomer();
            $this->provisionStorageBucket();
            $this->createSubdomain(); // If using external DNS

            // Mark as ready
            $this->tenant->update(['status' => 'active', 'provisioned_at' => now()]);

            // Notify
            $this->tenant->owner->notify(new TenantReady($this->tenant));

        } catch (Throwable $e) {
            $this->tenant->update(['status' => 'provisioning_failed']);
            throw $e;
        } finally {
            TenantContext::reset();
        }
    }
}

Tenant Lifecycle States

Provisioning

Creating resources

Trial

Limited features, time-bound

Active

Paying customer

Suspended

Payment failed, restricted access

Cancelled

Data retained, no access

Each state affects what the tenant can do. Suspended tenants might have read-only access. Cancelled tenants might have a data export option but no operational access. State transitions should be audited.

Tenant Deletion and Data Retention

Deleting a tenant is rarely a simple $tenant->delete(). Consider data retention requirements (legal, contractual), related resource cleanup (Stripe subscriptions, storage buckets, DNS records), and soft deletion for recovery during a grace period.

class DeleteTenantJob implements ShouldQueue
{
    public function __construct(public Tenant $tenant) {}

    public function handle(): void
    {
        // Cancel external subscriptions
        $this->cancelStripeSubscription();

        // Schedule storage cleanup (after retention period)
        CleanupTenantStorageJob::dispatch($this->tenant)
            ->delay(now()->addDays(30));

        // Soft delete tenant and cascade
        $this->tenant->update(['deleted_at' => now(), 'status' => 'deleted']);

        // Hard delete after retention period
        PurgeTenantDataJob::dispatch($this->tenant->id)
            ->delay(now()->addDays(90));
    }
}

Tenant Customisation

Multi-tenant applications often need per-tenant customisation: configuration, branding, and feature access. The goal is flexibility without creating unmaintainable configuration sprawl.

Configuration Storage Patterns

Columns on the tenant table

For settings that every tenant has and that are queried frequently: plan, timezone, locale, primary colour. Indexed, type-safe, easy to query.

Limit: Doesn't scale if you have dozens of optional settings. Use for core configuration only.

JSON column for settings

A settings JSON column holds optional configuration: notification preferences, integration credentials, UI preferences. Flexible, schema-less.

Limit: Harder to query across tenants. Use for tenant-specific overrides, not core data.

Related settings table

A tenant_settings table with key-value pairs. Maximum flexibility, easy to add new settings without migrations.

Limit: Multiple queries to load all settings. Cache aggressively.

Feature flags

A tenant_features pivot table tracks which features each tenant has access to. Enables gradual rollouts and tiered access.

Limit: Adds complexity to feature checks. Use a caching layer.

Branding and White-Labelling

White-label applications need per-tenant visual identity: logo, colours, custom domain, email sender identity, and sometimes terminology changes.

// In a Blade layout


@if($tenant->logo_path)
    {{ $tenant->name }}
@else
    {{ $tenant->name }}
@endif

For deeper customisation (terminology, content), consider a translation-style approach where tenants can override default strings.

Feature Tiers

Different subscription plans unlock different features. The cleanest pattern: tenants have a plan, plans have features, and a helper method checks access:

// On the Tenant model
public function hasFeature(string $feature): bool
{
    return $this->plan->features->contains('slug', $feature);
}

// In application code
if (TenantContext::get()->hasFeature('advanced-reporting')) {
    // Show advanced reports
}

// In Blade
@can('feature', 'advanced-reporting')
    Advanced Reports
@endcan

Cache the feature check. It's called frequently, and plan/feature relationships change rarely.


Security Considerations

Data isolation is the primary security concern, but multi-tenant applications have additional attack surfaces.

Preventing Data Leakage

✓
Global scopes on all tenant models: Apply the BelongsToTenant trait consistently. Missing one model creates a leakage path.
✓
Explicit ownership validation: Even with scopes, validate that URL parameters (IDs) belong to the current tenant. Defence in depth.
✓
Review raw queries: Any SQL that bypasses Eloquent bypasses scopes. Review carefully; log cross-tenant queries.
✓
Test isolation explicitly: Automated tests should create data for tenant A, switch to tenant B, and verify the data is inaccessible.

URL Manipulation

Users might try changing IDs in URLs to access other records. Tenant scoping should prevent this, but add explicit validation for sensitive operations:

public function show(Order $order)
{
    // Route model binding already applies tenant scope, but be explicit:
    abort_unless($order->tenant_id === TenantContext::id(), 403);

    return view('orders.show', compact('order'));
}

File Storage Isolation

Uploaded files need the same isolation as database records. The pattern: tenant-prefixed paths, validation on access, signed URLs for downloads.

// Upload
$path = $file->store("tenants/{$tenant->id}/documents");

// Access validation in controller
public function download(Document $document)
{
    abort_unless($document->tenant_id === TenantContext::id(), 403);

    return Storage::download($document->path);
}

// Or use signed URLs with expiry
$url = Storage::temporaryUrl($document->path, now()->addMinutes(5));

For S3, consider separate buckets per tenant (maximum isolation) or bucket policies that restrict access by path prefix (simpler, less isolated). For PostgreSQL databases, Row-Level Security provides an additional layer of database-enforced tenant isolation.

Cache Key Isolation

A cache hit from the wrong tenant is a data breach. Prefix all cache keys with the tenant ID:

// Wrong: cache key collision risk
Cache::get('dashboard_stats');

// Right: tenant-scoped key
Cache::get("tenant:{$tenantId}:dashboard_stats");

// Or use a helper
function tenant_cache_key(string $key): string
{
    return "tenant:" . TenantContext::id() . ":{$key}";
}

Same principle applies to session data, rate limiting keys, and any other keyed storage.

Audit Logging

Log all cross-tenant access (admin operations, support access). Include who, when, what, and why. This connects to broader audit trail requirements, with cross-tenant access as particularly sensitive:

AuditLog::create([
    'actor_id' => auth()->id(),
    'actor_type' => 'admin',
    'tenant_id' => $tenant->id,
    'action' => 'viewed_orders',
    'reason' => $request->input('support_ticket_id'),
    'ip_address' => $request->ip(),
]);

Performance Isolation

In a shared-database architecture, one tenant's heavy usage can affect others. This is the "noisy neighbour" problem.

Database Performance

The tenant_id column should be indexed on every tenant-scoped table. For frequently-queried tables, it should be the first column in composite indexes:

// Good: tenant_id first, then commonly filtered columns
Schema::table('orders', function (Blueprint $table) {
    $table->index(['tenant_id', 'status', 'created_at']);
});

// Query optimiser can use the index for:
// - WHERE tenant_id = X
// - WHERE tenant_id = X AND status = Y
// - WHERE tenant_id = X AND status = Y AND created_at > Z

Monitor slow queries by tenant. If one tenant's data size causes performance issues, consider archiving old data, sharding their data to a separate database, or adding tenant-specific indexes.

Queue Isolation

High-volume tenants should have dedicated queues to prevent their job backlogs from blocking other tenants:

// In the tenant record
$tenant->dedicated_queue = 'tenant_123';

// Job routing
public function viaQueue(): string
{
    return Tenant::find($this->tenantId)->dedicated_queue ?? 'default';
}

// Run dedicated workers
php artisan queue:work --queue=tenant_123

Rate Limiting

Apply rate limits per tenant, not just per user. A tenant with 50 users hitting an endpoint simultaneously should be limited as an entity:

RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(1000)->by(TenantContext::id());
});

Resource Quotas

For storage, record counts, or compute usage, enforce quotas at the tenant level:

class Order extends Model
{
    protected static function booted()
    {
        static::creating(function (Order $order) {
            $tenant = TenantContext::get();
            $limit = $tenant->plan->order_limit;

            if ($limit && Order::count() >= $limit) {
                throw new QuotaExceededException('Order limit reached');
            }
        });
    }
}

Testing Multi-Tenant Applications

Multi-tenant applications need additional test coverage. The standard unit and feature tests aren't enough; you need tests that specifically verify tenant isolation.

Isolation Tests

public function test_tenant_cannot_access_other_tenant_orders()
{
    $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 orders
    $this->assertCount(1, Order::all());
    $this->assertTrue(Order::all()->contains($orderB));
    $this->assertFalse(Order::all()->contains($orderA));

    // Direct ID lookup should fail
    $this->assertNull(Order::find($orderA->id));
}

Test All Contexts

Tenant isolation must work in all execution contexts. Write tests that verify scoping in HTTP requests, queued jobs, console commands, and scheduled tasks:

public function test_queued_job_maintains_tenant_context()
{
    $tenant = Tenant::factory()->create();
    TenantContext::set($tenant);

    $order = Order::factory()->create();

    ProcessOrderJob::dispatch($order);

    // Process the job
    $this->artisan('queue:work', ['--once' => true]);

    // Verify the job ran in the correct tenant context
    $order->refresh();
    $this->assertEquals($tenant->id, $order->processed_by_tenant_id);
}

Fuzz Testing for Leakage

For critical applications, add fuzz tests that create many tenants with similar data and verify no cross-tenant leakage under various access patterns:

public function test_no_leakage_under_concurrent_access()
{
    $tenants = Tenant::factory()->count(10)->create();

    foreach ($tenants as $tenant) {
        TenantContext::set($tenant);
        Order::factory()->count(100)->create(['reference' => $tenant->id]);
    }

    foreach ($tenants as $tenant) {
        TenantContext::set($tenant);
        $orders = Order::all();

        foreach ($orders as $order) {
            $this->assertEquals(
                $tenant->id,
                $order->reference,
                "Found order from tenant {$order->reference} while querying as tenant {$tenant->id}"
            );
        }
    }
}

Packages and Custom Implementation

Laravel has several multi-tenancy packages. Evaluate them against your specific requirements before adopting.

Tenancy for Laravel (stancl/tenancy)

Comprehensive package supporting database-per-tenant, schema-per-tenant, and single-database approaches. Tenancy for Laravel handles tenant identification, database switching, and asset customisation. Well-documented, actively maintained.

Consider when: You need database-per-tenant isolation or want a batteries-included solution.

Laravel Multitenancy (Spatie)

Lightweight and flexible. Spatie's Laravel Multitenancy focuses on tenant identification and context; leaves database scoping to you. Fits well with existing codebases.

Consider when: You want control over the implementation details or are adding multi-tenancy to an existing application.

Custom implementation

The patterns in this article form a complete custom implementation. A few hundred lines of code, full control, no package dependencies to track.

Consider when: You have specific requirements that don't fit packages, or you prefer owning the implementation for long-term maintenance.

We use custom implementations for most projects. The patterns are straightforward, the code is transparent, and we avoid dependency on packages that might change direction or become unmaintained. For projects requiring database-per-tenant isolation, we evaluate packages case by case.


What You Get

  • ✓
    Complete isolation Tenants can never access each other's data. Verified by architectural patterns and explicit tests.
  • ✓
    Simple operations One codebase to deploy and maintain. Schema changes apply once. One monitoring stack.
  • ✓
    Per-tenant customisation Branding, features, configuration, and quotas per client. White-label ready.
  • ✓
    Efficient scaling Add tenants without infrastructure complexity. Noisy neighbours contained with queue and rate limit isolation.
  • ✓
    Cross-tenant insights Platform-level analytics without distributed data aggregation. Benchmark tenants against cohorts.
  • ✓
    Tested thoroughly Isolation verified in HTTP requests, queued jobs, console commands, and scheduled tasks. Defence in depth.

SaaS infrastructure that scales with your customer base. The architecture you need for products that serve dozens, hundreds, or thousands of clients from a single deployment.


Build Your Multi-Tenant Application

We build multi-tenant Laravel applications for SaaS products and B2B platforms. Tenant isolation by design, not afterthought. Per-tenant customisation supported. Operational complexity minimised. The architecture for products that scale with their customer base.

Let's talk about multi-tenancy →
Graphic Swish