Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 051 / 100

Multi-tenant SaaS with Laravel: best practices explained

One codebase, many customers.

Expona Admin8 min read

Building isolation that doesn't break your deployment

Multi-tenant SaaS with Laravel: best practices explained—a phrase that captures the central tension in scaling a single codebase across many customers. Every team building SaaS runs headlong into this problem: how do you serve Customer A and Customer B from the same application without Customer A ever seeing Customer B's data? The answer isn't one-size-fits-all, and the choice you make early affects everything downstream, from how you migrate schemas to how you test isolation to how your ops team sleeps at night.

Three approaches to tenant isolation

Before writing a line of code, you need to decide how much isolation your business actually needs. We've seen teams pick the wrong model and spend months retrofitting. The three practical options sit on a spectrum between simplicity and fortress-grade isolation.

Shared tables with tenant_id column

In this model, every table has a tenant_id column. A single database holds all customers' data. It's the simplest approach to deploy and the fastest to ship initially. The tradeoff is strict: you must scope every query, every relationship, every migration to the current tenant, or data leaks.

This works when your team is small, compliance requirements are light, and you trust your query discipline. We've seen it work at early-stage SaaS companies handling non-sensitive data. The operational overhead is minimal—one database, one backup, one restore.

php
// Migration: add tenant_id to every table
Schema::create('customers', function (Blueprint $table) {
    $table->id();
    $table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
    $table->string('name');
    $table->timestamps();
});

// Model always filters by tenant
class Customer extends Model
{
    protected static function boot()
    {
        parent::boot();
        static::addGlobalScope('tenant', function (Builder $builder) {
            if (auth()->check() && auth()->user()->tenant_id) {
                $builder->where('tenant_id', auth()->user()->tenant_id);
            }
        });
    }
}

Schema per tenant

Each tenant gets its own schema (or set of tables with a shared prefix) within a single database instance. This provides stronger isolation than tenant_id columns while keeping deployment simpler than separate databases. You can still backup and restore all tenants together, but you gain schema-level separation that prevents accidental cross-tenant queries.

The cost: migrations become more complex. Every migration must run against every schema. If you have 50 tenants and a migration fails on tenant 30, your deployment is stuck.

php
// Resolve which schema to use
class TenantMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        $tenant = Tenant::whereSubdomain(
            $this->extractSubdomain($request->getHost())
        )->firstOrFail();

        // Set connection config to use this schema
        DB::connection()->setDatabaseName("tenant_{$tenant->id}");
        app()->instance('tenant', $tenant);

        return $next($request);
    }

    private function extractSubdomain(string $host): string
    {
        $parts = explode('.', $host);
        return count($parts) > 2 ? $parts[0] : 'default';
    }
}

Database per tenant

The fortress approach: each tenant has a completely separate database instance. This is ideal for regulated industries (fintech, healthcare) where auditors want absolute proof of isolation. It's the slowest to deploy and the heaviest operationally—you're now managing tens or hundreds of database instances—but it's bulletproof.

Some teams use a hybrid: a shared "platform" database for billing, authentication, and tenant metadata, and a private database per tenant for their data. This splits the pain.

php

// In config/database.php, build connection pool dynamically
'tenants' => [
    'driver' => 'pgsql',
    'host' => env('DB_HOST'),
    'port' => env('DB_PORT'),
    'database' => "tenant_{$tenantId}", // Built at request time
    'username' => env('DB_USERNAME'),
    'password' => env('DB_PASSWORD'),
],

// Middleware switches the connection
class DatabasePerTenantMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        $tenant = $this->resolveTenant($request);
        config([
            "database.connections.tenants.database" => "tenant_{$tenant->id}"
        ]);
        app()->instance('tenant', $tenant);

        return $next($request);
    }
}

Global scopes: the enforcement mechanism

No matter which isolation model you choose, global scopes are how you prevent accidental data leaks. A global scope automatically filters every query on a model by the current tenant. If you forget the scope, your tests catch it (see below). If a scope somehow gets bypassed, your code fails loudly instead of silently returning the wrong data.

Build the scope as a reusable trait so every model uses it consistently:

php
trait BelongsToTenant
{
    protected static function bootBelongsToTenant()
    {
        static::addGlobalScope('tenant', function (Builder $builder) {
            $tenant = app('tenant') ?? auth()->user()?->tenant;
            if ($tenant) {
                $builder->where('tenant_id', $tenant->id);
            }
        });
    }

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

// Use it everywhere
class Invoice extends Model
{
    use BelongsToTenant;
}

class LineItem extends Model
{
    use BelongsToTenant;
}

The global scope fires on every query: Invoice::all(), Invoice::find(123), Invoice::with('lineItems'), eager loads, everything. No scope to forget. That's the power.

Resolve tenant early in middleware

The tenant must be resolved before any user code runs. We resolve it in middleware, store it in the container, and make it available to every downstream service: models, policies, jobs, everything.

Most teams resolve from the subdomain (e.g., acme.app.com) or from an API token that includes the tenant ID.

php
// Subdomain-based tenant resolution
class ResolveTenantFromSubdomain
{
    public function handle(Request $request, Closure $next)
    {
        $subdomain = $this->getSubdomain($request->getHost());
        
        if ($subdomain === 'www' || !$subdomain) {
            // Marketing site or admin panel
            return $next($request);
        }

        $tenant = Tenant::query()
            ->where('slug', $subdomain)
            ->orWhere('subdomain', $subdomain)
            ->firstOrFail();

        app()->instance('tenant', $tenant);
        auth()->user()?->update(['current_tenant_id' => $tenant->id]);

        return $next($request);
    }

    private function getSubdomain(string $host): ?string
    {
        $parts = explode('.', str_replace('localhost', 'localhost.local', $host));
        return count($parts) > 1 ? $parts[0] : null;
    }
}

// For API tokens
class ResolveTenantFromToken
{
    public function handle(Request $request, Closure $next)
    {
        if ($request->bearerToken()) {
            $token = PersonalAccessToken::findToken($request->bearerToken());
            if ($token && $token->tokenable->tenant_id) {
                app()->instance('tenant', Tenant::findOrFail($token->tokenable->tenant_id));
            }
        }

        return $next($request);
    }
}

Resolve early, fail fast if the tenant is invalid. If a request has no valid tenant context, return 403 or redirect. Never silently degrade to no-tenant mode.

Test isolation: prove one tenant cannot see another's data

The most important tests are the ones that prove isolation. We write explicit tests that a query from Tenant A returns nothing for Tenant B's data. These tests are cheap insurance against subtle bugs that could live in production for months.

php
class TenantIsolationTest extends TestCase
{
    private Tenant $tenantA;
    private Tenant $tenantB;
    private User $userA;
    private User $userB;

    protected function setUp(): void
    {
        parent::setUp();

        $this->tenantA = Tenant::factory()->create(['slug' => 'tenant-a']);
        $this->tenantB = Tenant::factory()->create(['slug' => 'tenant-b']);

        $this->userA = User::factory()->for($this->tenantA)->create();
        $this->userB = User::factory()->for($this->tenantB)->create();
    }

    public function test_user_a_cannot_see_user_b_invoices()
    {
        $invoiceA = Invoice::factory()
            ->for($this->tenantA)
            ->create(['amount' => 100]);

        $invoiceB = Invoice::factory()
            ->for($this->tenantB)
            ->create(['amount' => 200]);

        $this->actingAs($this->userA);
        app()->instance('tenant', $this->tenantA);

        $invoices = Invoice::all();

        $this->assertCount(1, $invoices);
        $this->assertEquals($invoiceA->id, $invoices->first()->id);
        $this->assertEquals(100, $invoices->first()->amount);
    }

    public function test_removing_global_scope_exposes_all_tenants()
    {
        Invoice::factory()->for($this->tenantA)->create();
        Invoice::factory()->for($this->tenantB)->create();

        app()->instance('tenant', $this->tenantA);

        // This query should never happen in production, but let's catch it:
        $invoicesWithoutScope = Invoice::withoutGlobalScope('tenant')->get();

        $this->assertCount(2, $invoicesWithoutScope);
        $this->fail('withoutGlobalScope should be forbidden or logged');
    }

    public function test_relationship_loads_respect_tenant_scope()
    {
        $invoiceA = Invoice::factory()
            ->for($this->tenantA)
            ->has(LineItem::factory()->count(2))
            ->create();

        $invoiceB = Invoice::factory()
            ->for($this->tenantB)
            ->has(LineItem::factory()->count(3))
            ->create();

        app()->instance('tenant', $this->tenantA);

        $loadedInvoice = Invoice::with('lineItems')->find($invoiceA->id);

        $this->assertCount(2, $loadedInvoice->lineItems);
    }
}

Write isolation tests early. Make them part of your CI pipeline. A test that fails on isolation is a test that saves you from a data breach.

Common mistakes

  • Forgetting the global scope on a model. You add a new model, forget the BelongsToTenant trait, and suddenly that data is visible across tenants. Use code review and a linter rule to catch this.
  • Resolving tenant too late. If you resolve tenant in a service instead of middleware, requests can flow through unauthenticated code without a tenant context. Middleware runs first; put tenant resolution there.
  • Bypassing scopes in migrations. Use withoutGlobalScope only in migrations and admin commands, never in user-facing code. Add a comment explaining why.
  • Testing with only one tenant. If your test suite only creates Tenant A, you'll never catch cross-tenant leaks. Always create at least two tenants in isolation tests.
  • Assuming tokens or subdomains are unique. A subdomain collision or a leaked token can be a security incident. Validate that a subdomain or token actually belongs to the claimed tenant before trusting it.

Key takeaways

  • Choose your isolation model early: shared tables (simple, risky), schema per tenant (balanced), or database per tenant (fortress, expensive).
  • Use global scopes as your enforcement mechanism. Every model that holds tenant data should use the same trait.
  • Resolve the tenant in middleware, before any user code runs. Store it in the container so every service can access it.
  • Test isolation explicitly. Write tests that prove Tenant A cannot see Tenant B's data. Make these tests run on every commit.
  • Code review is your second line of defense. A missed global scope or a withoutGlobalScope in user code should block a PR.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles