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_connectionslimit 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.sqlin 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.













