Tech Verse Logo
Enable dark mode
Laravel Multi Tenancy: Single vs Multi Database

Laravel Multi Tenancy: Single vs Multi Database

Md. Mostafijur RahmanMMd. Mostafijur Rahman

Md. Mostafijur Rahman

5 min read

The Realities of Laravel Multi Tenancy

When you build a software-as-a-service app, choosing how to isolate tenant data is the biggest technical decision you'll make. Get it right, and your infrastructure stays fast and cheap. Get it wrong, and you'll spend nights cleaning up cross-tenant data leaks or waiting 45 minutes for database migrations to finish during a hotfix deploy.

In Laravel 12 running on PHP 8.3, you have two primary options: single-database tenancy with column scoping, or multi-database tenancy where each account gets its own database. Both work well, but they fail in completely different ways when your application scales.

Single-Database Tenancy: Scoping Everything by Hand

Single-database tenancy keeps all customer records inside a single MySQL or PostgreSQL database. Every table that stores tenant-owned data gets a tenant_id column. You rely on Eloquent global scopes to append WHERE tenant_id = ? to every query automatically.

Implementing Eloquent Tenant Scopes

Here is a clean implementation of a tenant trait using PHP 8.3 features and Laravel 12 scopes. We attach a global scope that reads the current active tenant from a singleton stored in the app container.

namespace App\Models\Concerns;

use App\Models\Scopes\TenantScope;
use App\Services\TenantContext;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

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

        static::creating(function (Model $model) {
            if (! $model->getAttribute('tenant_id') && $tenant = app(TenantContext::class)->get()) {
                $model->setAttribute('tenant_id', $tenant->id);
            }
        });
    }

    public function scopeWithoutTenant(Builder $query): Builder
    {
        return $query->withoutGlobalScope(TenantScope::class);
    }
}

The companion scope checks if a tenant context exists before scoping query builder instances:

namespace App\Models\Scopes;

use App\Services\TenantContext;
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 ($tenant = app(TenantContext::class)->get()) {
            $builder->where($model->getTable() . '.tenant_id', '=', $tenant->id);
        }
    }
}

Where Single-Database Tenancy Breaks

The single-database approach is fast to query because database connections stay open and pooled. However, security bugs are always one forgotten scope away. Here are the common failure modes:

  • Raw SQL and Query Builder leaks: If a developer writes DB::table('invoices')->where('status', 'unpaid')->get() instead of using the Eloquent model, global scopes do not run. You just returned every customer's unpaid invoices.
  • Unindexed tenant filtering: Every composite index on a tenant-aware table must begin with tenant_id. If you index (created_at, status) instead of (tenant_id, created_at, status), MySQL will scan millions of rows across all tenants before filtering.
  • No hard isolation for strict compliance: Enterprise clients subject to SOC2 or HIPAA often require dedicated encryption keys and isolated backups. Splitting one customer's data out of a shared database requires custom dump scripts.

Multi-Database Tenancy: Dynamic Connection Switching

Multi-database tenancy gives every client their own isolated database schema. Your central database holds only tenants, subscriptions, and global routing data. When an HTTP request or queue job starts, Laravel switches the default database connection on the fly.

Dynamic Connection Switcher Middleware

To switch databases cleanly in Laravel 12 without leaking connections, you must purge the existing connection before reconfiguring it. Otherwise, Laravel reuses the old PDO instance and executes queries against the previous tenant's database.

namespace App\Http\Middleware;

use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpFoundation\Response;

class IdentifyTenant
{
    public function handle(Request $request, Closure $next): Response
    {
        $host = $request->getHost();
        $tenant = Tenant::where('domain', $host)->firstOrFail();

        $this->switchDatabase($tenant->db_name);

        app()->instance(Tenant::class, $tenant);

        return $next($request);
    }

    private function switchDatabase(string $dbName): void
    {
        DB::purge('tenant');

        Config::set('database.connections.tenant.database', $dbName);

        DB::reconnect('tenant');
        DB::setDefaultConnection('tenant');
    }
}

The Queue Job Gotcha

The single biggest issue with multi-database tenancy in production involves asynchronous workers running via Laravel Horizon or queue:work. Long-running CLI processes retain database state across job executions. If Job A runs for Tenant 10 and updates connection settings, Job B will run against Tenant 10's database unless you explicitly reset or re-evaluate the tenant context at the start of every job.

To fix this in Laravel 12, register a job processing listener or inject tenant identifiers into your job payloads and call switchDatabase() inside the job handle method.

Frontend Integration: Next.js 16 and React 19

If you build your frontend with Next.js 16 App Router and React 19, cache key context leaks are a huge risk. Server Components rendering user data must send tenant identifying headers to Laravel 12.

Pass custom HTTP headers like X-Tenant-ID from Next.js middleware, and configure Laravel's dynamic connection middleware to read either subdomains or headers. When caching responses in Redis on the PHP side, tag or prefix cache keys with tenant:{id}: to prevent cross-account cache poisoning.

The Migration Burden: Schema Updates

This is where the two models diverge sharply in developer experience and operational overhead.

Single-Database Migrations

Running schema changes on a single database is standard practice. You run php artisan migrate during deployment. A new column or table takes 200ms to apply. Index additions on large tables require concurrent index builds (like PostgreSQL CREATE INDEX CONCURRENTLY), but you execute the operation once.

Multi-Database Migrations

When you have 500 tenants across 500 databases, a routine deployment requires running migrations 500 times. A sequential loop running Artisan::call('migrate', ...) across 500 databases takes anywhere from 45 seconds to several minutes depending on network latency.

If migration 230 fails due to a network timeout or locked table, your app enters a fractured state where half your tenants are on schema version A and the rest are on version B. To make multi-database deployments reliable, you must run migrations in parallel using Laravel 12 process pools or queue tasks across worker nodes.

Performance and Resource Trade-offs

Here is how both approaches compare under production traffic:

  • Connection Pools: Single-database setups require fewer persistent connections. Multi-database setups can easily exceed MySQL's max_connections limit if 200 tenants each spawn 5 active connections during high traffic. You will need a connection proxy like PgBouncer for PostgreSQL or ProxySQL for MySQL.
  • Memory Footprint: Scoped queries on a single database require minimal extra RAM in PHP 8.3. Connection switching re-instantiates PDO objects, increasing per-request memory overhead by about 1.5MB to 3MB per dynamic switch.
  • Backup and Restore: Multi-database shines here. If Tenant 42 accidentally deletes their data, you restore tenant_42.sql in 10 seconds. In a single-database setup, restoring one tenant requires restoring a snapshot to a temporary server and running selective SQL export queries.

Which Pattern Should You Choose?

Don't pick multi-database multi tenancy just because it feels safer. For 90% of SaaS products, single-database tenancy with global scopes is the practical choice. It offers faster deployments, simple analytics queries across all accounts, and much lower server costs.

Choose multi-database tenancy only when required by enterprise contract terms, compliance standards that mandate separate databases, or when individual tenants store hundreds of gigabytes of data and require dedicated hardware isolation.

Md. Mostafijur RahmanMMd. Mostafijur Rahman

WRITTEN BY

Md. Mostafijur Rahman

    Latest Posts

    View All

    Local LLM Development with Ollama and Hardware Limits

    Local LLM Development with Ollama and Hardware Limits

    LLM API Pricing Comparison for Side Projects

    LLM API Pricing Comparison for Side Projects

    Building Semantic Search with Laravel 12 and Next.js 16

    Building Semantic Search with Laravel 12 and Next.js 16

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Fine-Tuning vs RAG vs Prompts: Choosing the Right AI Tool

    Wiring LLMs with Tool Function Calling

    Wiring LLMs with Tool Function Calling

    LLM API Rate Limit Caching and Quota Guarding

    LLM API Rate Limit Caching and Quota Guarding

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Streaming LLM Responses with Laravel 12 and Next.js 16

    Pgvector Similarity Search in Production Postgres

    Pgvector Similarity Search in Production Postgres

    React Drag and Drop with dnd-kit and Laravel

    React Drag and Drop with dnd-kit and Laravel

    Building a Production RAG Pipeline in PHP 8.3

    Building a Production RAG Pipeline in PHP 8.3