DEV Community

Faisal Nadeem
Faisal Nadeem

Posted on

Multi-Tenant Architecture Patterns in Laravel: Choosing the Right Database Strategy

Every Laravel SaaS product hits the same fork in the road once it has more than one paying customer: how do you keep Tenant A's data from ever touching Tenant B's, while still shipping features at a reasonable pace? The answer isn't "add a tenant_id column and move on" — that works until it doesn't, usually right when a query is missed and one customer sees another customer's invoices in a support ticket. Multi-tenancy in Laravel comes down to three real strategies, each with a different failure mode, and picking the wrong one for your growth stage costs real engineering time later.

The Three Strategies

Shared database, shared schema (row-level tenancy). Every tenant's data lives in the same tables, distinguished by a tenant_id foreign key. This is the default starting point for almost every Laravel SaaS product, and for good reason — one set of migrations, one connection, and standard Eloquent relationships work exactly like a single-tenant app.

Shared database, separate schemas. Each tenant gets their own PostgreSQL schema (or, less commonly, a MySQL database) within the same server. Queries are automatically scoped by schema rather than by an explicit WHERE clause, which removes an entire category of "forgot the tenant filter" bugs.

Separate databases per tenant. Each tenant gets a fully isolated database, sometimes on separate infrastructure entirely. This is the strongest isolation guarantee and the one enterprise buyers ask about first, but it's also the most operationally expensive: migrations, backups, and connection pooling all multiply by tenant count.

Most Laravel SaaS products should start with row-level tenancy and only move to schema or database isolation when a specific customer (usually an enterprise one with a security review) requires it, or when tenant count and query volume genuinely justify the operational cost.

Row-Level Tenancy: The Part Everyone Gets Wrong

The naive implementation adds tenant_id to every table and trusts every controller to filter by it. This fails the first time someone writes Invoice::find($id) instead of scoping through the tenant relationship, or the first time a background job processes records without knowing which tenant it's running for.

The fix is a global scope applied automatically to every query, so tenant filtering isn't something a developer has to remember — it's something the framework enforces:

namespace App\Models\Scopes;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;

class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if ($tenantId = app('currentTenant')?->id) {
            $builder->where($model->getTable() . '.tenant_id', $tenantId);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Applied via a trait so every tenant-owned model picks it up consistently:

namespace App\Models\Concerns;

use App\Models\Scopes\TenantScope;

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

        static::creating(function ($model) {
            if (empty($model->tenant_id) && $tenant = app('currentTenant')) {
                $model->tenant_id = $tenant->id;
            }
        });
    }

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

Now Invoice::all() and Invoice::find($id) are automatically tenant-scoped, and creating a new Invoice automatically stamps the current tenant. The critical piece is app('currentTenant') — this needs to be resolved once per request (typically in middleware, from the authenticated user's tenant relationship or from a subdomain/header) and bound into the container before any tenant-scoped query runs.

class IdentifyTenant
{
    public function handle($request, Closure $next)
    {
        $tenant = $request->user()?->tenant
            ?? Tenant::where('domain', $request->getHost())->first();

        if (! $tenant) {
            abort(404);
        }

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

        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

The Queue Job Trap

Global scopes solve the request-response cycle, but queued jobs run outside that cycle entirely — there's no request, so there's no tenant context, unless you explicitly carry it. This is the single most common multi-tenancy bug in production Laravel SaaS apps: a job dispatched inside a tenant-scoped request runs later, on a worker process that has no idea which tenant it belongs to, and either throws on a missing scope or — worse — silently operates without one.

class GenerateInvoicePdf implements ShouldQueue
{
    public function __construct(
        public int $tenantId,
        public int $invoiceId,
    ) {}

    public function handle(): void
    {
        app()->instance('currentTenant', Tenant::find($this->tenantId));

        $invoice = Invoice::find($this->invoiceId);
        // ... generate PDF
    }
}
Enter fullscreen mode Exit fullscreen mode

Never rely on ambient state for a job's tenant context. Pass the tenant ID explicitly in the constructor and rebind it at the top of handle(), every time, even when it feels redundant.

Schema and Database Isolation: When Row-Level Isn't Enough

Once a customer's procurement team asks "can our data live in a database no other customer's code can query, even in theory," row-level tenancy stops being a satisfying answer regardless of how well-tested the global scope is. Laravel's multi-database connection support makes per-tenant databases workable without a framework rewrite:

class SwitchTenantDatabase
{
    public function handle($request, Closure $next)
    {
        $tenant = app('currentTenant');

        config([
            'database.connections.tenant' => [
                'driver' => 'mysql',
                'host' => $tenant->db_host,
                'database' => $tenant->db_name,
                'username' => $tenant->db_username,
                'password' => decrypt($tenant->db_password),
            ],
        ]);

        DB::purge('tenant');
        DB::reconnect('tenant');

        return $next($request);
    }
}
Enter fullscreen mode Exit fullscreen mode

Models that live in the tenant database declare protected $connection = 'tenant';, and migrations need a per-tenant runner (php artisan tenants:migrate) that loops over every tenant's connection rather than running once globally. The operational cost is real: connection pool exhaustion becomes a concern past a few hundred concurrent tenant connections, and a schema change now means running (and verifying) the same migration N times instead of once. This is the right tradeoff for a security-sensitive enterprise tier, and the wrong one to reach for on day one.

A Practical Migration Path

Start every Laravel SaaS product with row-level tenancy and a global scope. Build the BelongsToTenant trait and IdentifyTenant middleware into the foundation from the first migration, not as a retrofit — retrofitting tenant scoping onto an app with dozens of unscoped queries already in production is a much larger project than building it in from day one. If and when a specific customer segment demands stronger isolation, migrate just that segment to separate databases rather than migrating the entire product; most SaaS businesses end up running a hybrid — row-level tenancy for the majority of customers, database-per-tenant for a handful of enterprise accounts — rather than one strategy for everyone.

Multi-tenant architecture decisions compound. Getting the foundation right when a SaaS product has ten tenants is a day of work; getting it right when it has a thousand tenants and a live enterprise contract riding on a security review is a multi-month project with the business watching. If you're scoping a new Laravel SaaS build or auditing an existing one for tenant-isolation gaps before a customer's security review, it's the kind of decision worth getting a second, experienced opinion on before the schema is locked in.

Top comments (0)