Posts

Multi-Tenancy in Laravel: A Practical Guide for SaaS Architecture

July 20, 2025·4 min read
#laravel#multi-tenancy#saas#architecture#php

If you've ever built an application that needs to serve multiple clients or organizations within a single codebase — welcome to the world of multi-tenancy.

Multi-tenancy is an architecture where a single application instance serves multiple tenants (clients/organizations), with data isolated from one another.

This article covers the concept, strategies, and practical implementation in Laravel.

Why Multi-Tenancy?

Imagine you're building a ticketing system. Without multi-tenancy, each client needs a separate deployment — separate server, separate database, separate maintenance. Expensive and not scalable.

With multi-tenancy:

3 Database Strategies for Multi-Tenancy

1. Shared Database, Shared Schema (Column-based)

All tenants use 1 database, 1 table. Differentiated by a tenant_id column.

// Every query must filter by tenant_id
$orders = Order::where('tenant_id', auth()->user()->tenant_id)->get();

Pros: Simple, cheap, easy to maintain.

Cons: Risk of data leakage if you forget to filter. Requires high discipline.

2. Shared Database, Separate Schema

One database, but each tenant has its own schema (PostgreSQL-friendly).

Pros: Better isolation than option 1.

Cons: More complex migrations. Not ideal for MySQL.

3. Separate Database per Tenant

Each tenant gets their own database. Full isolation.

// Switch database connection per tenant
Config::set('database.connections.tenant.database', $tenant->database_name);
DB::purge('tenant');

Pros: Perfect isolation. Per-tenant backup/restore is easy.

Cons: More expensive. Migrations must run across all databases.

Which Strategy to Choose?

CriteriaColumn-basedSeparate DB
CostLowHigh
Data isolationLowHigh
ScalabilityHighMedium
ComplexityLowHigh
Best forSaaS startups, MVPsEnterprise, regulated industries

Rule of thumb: Start with column-based. Migrate to separate databases when you have compliance requirements or enterprise clients that need full isolation.

Implementation in Laravel

Approach 1: Global Scope (Column-based)

The simplest way — use a Global Scope to auto-filter by tenant.

// app/Models/Traits/BelongsToTenant.php
trait BelongsToTenant
{
    protected static function bootBelongsToTenant()
    {
        static::addGlobalScope('tenant', function ($query) {
            if (auth()->check()) {
                $query->where('tenant_id', auth()->user()->tenant_id);
            }
        });

        static::creating(function ($model) {
            if (auth()->check()) {
                $model->tenant_id = auth()->user()->tenant_id;
            }
        });
    }
}

Use it in your model:

class Order extends Model
{
    use BelongsToTenant;
}

// Now auto-filtered, no manual where needed
$orders = Order::all(); // already per-tenant

Approach 2: Middleware + Tenant Resolution

Resolve the tenant from a subdomain, header, or URL path.

// app/Http/Middleware/ResolveTenant.php
class ResolveTenant
{
    public function handle($request, Closure $next)
    {
        $subdomain = explode('.', $request->getHost())[0];
        $tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();

        app()->instance('current_tenant', $tenant);

        return $next($request);
    }
}

Approach 3: Package (stancl/tenancy)

For the separate database approach, the stancl/tenancy package handles a lot out of the box:

composer require stancl/tenancy
php artisan tenancy:install

This package handles:

Best Practices

1. Never Trust the Frontend for Tenant Filtering

// DON'T: tenant_id from the request
$orders = Order::where('tenant_id', $request->tenant_id)->get();

// DO: tenant_id from auth/session
$orders = Order::where('tenant_id', auth()->user()->tenant_id)->get();

2. Test Multi-Tenant Scenarios

Always test cross-tenant access:

public function test_user_cannot_access_other_tenant_data()
{
    $tenantA = Tenant::factory()->create();
    $tenantB = Tenant::factory()->create();

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

    $this->actingAs($tenantA->users->first())
        ->get("/orders/{$orderB->id}")
        ->assertForbidden();
}

3. Cache Must Be Tenant-Aware

// Prefix cache key with tenant_id
Cache::tags(["tenant_{$tenantId}"])->remember("orders", 3600, fn() => 
    Order::all()
);

4. Queue Jobs Must Carry Tenant Context

class ProcessOrder implements ShouldQueue
{
    public $tenantId;

    public function __construct($order)
    {
        $this->tenantId = $order->tenant_id;
    }

    public function handle()
    {
        // Set tenant context before processing
        app()->instance('current_tenant', Tenant::find($this->tenantId));
    }
}

When NOT to Use Multi-Tenancy

Conclusion

Multi-tenancy isn't just a technical choice — it's an architectural decision that impacts cost, security, and scalability of your product.

Start simple (column-based), scale up when needed (separate DB), and always prioritize data isolation at every layer.

Most importantly: test cross-tenant access from day one. Data leakage between tenants = game over for client trust.


Got questions about multi-tenancy implementation? Reach out via LinkedIn.