The Multi-Tenancy Decision That Shapes Everything
When you're building a SaaS product, the multi-tenancy strategy you choose in week one will echo through every sprint for the next three years. I've built multi-tenant platforms for forex brokerages, payment processors, and CRM companies, and the one constant is this: there's no universally correct approach. But there are approaches that are correct for your specific compliance, performance, and cost requirements.
Let me walk you through what I've learned building multi-tenant systems that serve hundreds of tenants and process millions of transactions monthly.
The Three Database Strategies
Every multi-tenant system boils down to one of three database strategies. Each has clear trade-offs.
1. Shared Database, Shared Schema
All tenants live in the same tables. A tenant_id column on every table acts as the partition key.
// A global scope that automatically filters by tenant
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if (auth()->check() && auth()->user()->tenant_id) {
$builder->where(
$model->getTable() . '.tenant_id',
auth()->user()->tenant_id
);
}
}
}
// Base model that all tenant-aware models extend
class TenantModel extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TenantScope());
static::creating(function (Model $model) {
if (auth()->check()) {
$model->tenant_id = auth()->user()->tenant_id;
}
});
}
}
This is the cheapest approach. One database, one connection pool, one backup job. But the risk is real: a missing WHERE tenant_id = ? clause means you've just leaked data between tenants. In fintech, that's a regulatory nightmare.
2. Shared Database, Separate Schemas
Each tenant gets their own PostgreSQL schema within the same database. Laravel doesn't support this natively, but it's straightforward to implement.
class SchemaManager
{
public function switchToTenant(Tenant $tenant): void
{
$schema = 'tenant_' . $tenant->id;
DB::statement("SET search_path TO {$schema}, public");
// Cache the current tenant for the request lifecycle
app()->instance('current_tenant', $tenant);
}
public function createTenantSchema(Tenant $tenant): void
{
$schema = 'tenant_' . $tenant->id;
DB::statement("CREATE SCHEMA IF NOT EXISTS {$schema}");
// Run migrations against the new schema
Artisan::call('migrate', [
'--path' => 'database/migrations/tenant',
'--database' => 'tenant',
]);
}
}
This gives you logical isolation without the overhead of managing hundreds of databases. Shared tables like plans, currencies, and countries stay in the public schema, while tenant-specific data lives in isolated schemas.
3. Separate Databases
Each tenant gets their own database. Maximum isolation, maximum operational overhead.
class DatabaseTenantManager
{
public function connect(Tenant $tenant): void
{
$connectionName = 'tenant';
config([
"database.connections.{$connectionName}" => [
'driver' => 'pgsql',
'host' => $tenant->db_host,
'port' => $tenant->db_port,
'database' => $tenant->db_name,
'username' => $tenant->db_username,
'password' => decrypt($tenant->db_password),
'charset' => 'utf8',
'prefix' => '',
'schema' => 'public',
'sslmode' => 'require',
],
]);
DB::purge($connectionName);
DB::reconnect($connectionName);
}
public function provisionDatabase(Tenant $tenant): void
{
// Connect to the admin database
$adminConn = DB::connection('admin');
$dbName = 'tenant_' . $tenant->slug;
$dbUser = 'user_' . $tenant->slug;
$dbPass = Str::random(32);
$adminConn->statement("CREATE DATABASE \"{$dbName}\"");
$adminConn->statement(
"CREATE USER \"{$dbUser}\" WITH ENCRYPTED PASSWORD '{$dbPass}'"
);
$adminConn->statement(
"GRANT ALL PRIVILEGES ON DATABASE \"{$dbName}\" TO \"{$dbUser}\""
);
$tenant->update([
'db_name' => $dbName,
'db_username' => $dbUser,
'db_password' => encrypt($dbPass),
]);
}
}
I reach for this when tenants have regulatory requirements for data residency, or when one tenant's query load could impact others. The cost is real — you need automation for backups, migrations, and monitoring across every database.
Middleware: The Tenant Resolution Layer
Tenant resolution is the first thing that happens on every request. I've used domain-based, subdomain-based, header-based, and path-based resolution. In practice, subdomain-based is the cleanest for SaaS products.
class ResolveTenantMiddleware
{
public function __construct(
private TenantRepository $tenants,
private TenantManager $manager,
) {}
public function handle(Request $request, Closure $next): Response
{
$identifier = $this->extractTenantIdentifier($request);
if (!$identifier) {
abort(404, 'Tenant not found.');
}
$tenant = $this->tenants->findByIdentifier($identifier);
if (!$tenant || !$tenant->is_active) {
abort(403, 'Tenant is inactive or does not exist.');
}
// Switch database connection
$this->manager->connect($tenant);
// Bind tenant to the container for the request lifecycle
app()->instance(Tenant::class, $tenant);
// Set tenant-specific config
config([
'mail.from.name' => $tenant->company_name,
'app.timezone' => $tenant->timezone,
'app.currency' => $tenant->default_currency,
]);
return $next($request);
}
private function extractTenantIdentifier(Request $request): ?string
{
$host = $request->getHost();
// Try subdomain first: acme.platform.com
$parts = explode('.', $host);
if (count($parts) > 2) {
return $parts[0];
}
// Fallback to custom domain mapping
return $this->tenants->findIdentifierByDomain($host);
}
}
Register it in your kernel so it runs early, before authentication:
// bootstrap/app.php (Laravel 11+)
return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware) {
$middleware->prepend(ResolveTenantMiddleware::class);
});
Tenant-Aware Job Processing
Queued jobs are where multi-tenancy gets tricky. Jobs run outside the HTTP request lifecycle, so there's no middleware to resolve the tenant. You need to explicitly capture and restore the tenant context.
trait TenantAwareJob
{
public string $tenantId;
public function initializeTenantAwareJob(): void
{
// Capture the current tenant when the job is dispatched
$tenant = app(Tenant::class);
$this->tenantId = $tenant->id;
}
public function resolveTenant(): Tenant
{
$tenant = Tenant::findOrFail($this->tenantId);
app(TenantManager::class)->connect($tenant);
app()->instance(Tenant::class, $tenant);
return $tenant;
}
}
class ProcessMonthlyStatement implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
use TenantAwareJob;
public function __construct(
public int $accountId,
public string $month,
) {
$this->initializeTenantAwareJob();
}
public function handle(StatementGenerator $generator): void
{
$this->resolveTenant();
$account = Account::findOrFail($this->accountId);
$generator->generate($account, $this->month);
}
}
Cache Isolation
If two tenants hit the same cache key, you've got a data leak. Prefix every cache key with the tenant identifier.
class TenantCacheManager
{
public function prefix(): string
{
$tenant = app(Tenant::class);
return "tenant_{$tenant->id}:";
}
public function get(string $key, mixed $default = null): mixed
{
return Cache::get($this->prefix() . $key, $default);
}
public function put(string $key, mixed $value, int $ttl = 3600): bool
{
return Cache::put($this->prefix() . $key, $value, $ttl);
}
public function flush(): bool
{
// Flush only this tenant's cache using tags
return Cache::tags(['tenant_' . app(Tenant::class)->id])->flush();
}
}
Running Migrations Across All Tenants
When you push a schema change, every tenant database needs it. A custom Artisan command handles this cleanly.
class MigrateAllTenants extends Command
{
protected $signature = 'tenants:migrate {--seed} {--fresh} {--tenant=}';
protected $description = 'Run migrations for all tenants';
public function handle(TenantManager $manager): int
{
$query = Tenant::where('is_active', true);
if ($id = $this->option('tenant')) {
$query->where('id', $id);
}
$tenants = $query->get();
$bar = $this->output->createProgressBar($tenants->count());
foreach ($tenants as $tenant) {
$this->info("\nMigrating: {$tenant->name}");
try {
$manager->connect($tenant);
$command = $this->option('fresh') ? 'migrate:fresh' : 'migrate';
Artisan::call($command, [
'--database' => 'tenant',
'--force' => true,
]);
if ($this->option('seed')) {
Artisan::call('db:seed', [
'--database' => 'tenant',
'--class' => 'TenantSeeder',
'--force' => true,
]);
}
$bar->advance();
} catch (\Exception $e) {
$this->error("Failed for {$tenant->name}: {$e->getMessage()}");
Log::error('Tenant migration failed', [
'tenant' => $tenant->id,
'error' => $e->getMessage(),
]);
}
}
$bar->finish();
$this->newLine();
$this->info('All tenant migrations complete.');
return self::SUCCESS;
}
}
Testing Multi-Tenant Code
Testing is where most teams cut corners, and it bites them hard. Every test that touches tenant-scoped data needs to set up and tear down the tenant context.
trait WithTenant
{
protected Tenant $tenant;
protected function setUpTenant(): void
{
$this->tenant = Tenant::factory()->create();
app(TenantManager::class)->connect($this->tenant);
app()->instance(Tenant::class, $this->tenant);
}
}
class AccountServiceTest extends TestCase
{
use WithTenant;
protected function setUp(): void
{
parent::setUp();
$this->setUpTenant();
}
public function test_accounts_are_isolated_between_tenants(): void
{
// Create account in tenant A
$accountA = Account::factory()->create(['name' => 'Tenant A Account']);
// Switch to tenant B
$tenantB = Tenant::factory()->create();
app(TenantManager::class)->connect($tenantB);
app()->instance(Tenant::class, $tenantB);
// Tenant B should NOT see Tenant A's account
$this->assertDatabaseMissing('accounts', ['name' => 'Tenant A Account']);
}
}
Which Strategy Should You Choose?
After building these systems across multiple companies, here's my framework:
- Under 50 tenants, low compliance requirements: Shared database with global scopes. Keep it simple.
- 50-500 tenants, moderate compliance: Separate schemas in PostgreSQL. Best balance of isolation and operational cost.
- 500+ tenants or strict data residency rules: Separate databases with automated provisioning. The ops overhead is justified.
The key principle is this: start with the simplest approach that meets your compliance requirements, and build your abstraction layer so you can migrate to a more isolated strategy later without rewriting your application code. The TenantManager pattern I've shown above makes this possible — your application code talks to the manager, and the manager handles connection switching regardless of the underlying strategy.
Key Takeaways
- Choose your database strategy based on compliance requirements first, performance second. In fintech, regulators often dictate your architecture more than your engineers do.
- Build a tenant resolution middleware that runs before everything else. Subdomain-based resolution is the cleanest pattern for most SaaS products.
- Never forget queued jobs. Jobs execute outside the request lifecycle, so you must explicitly serialize and restore the tenant context.
- Prefix all cache keys with the tenant identifier. A cache leak between tenants is just as bad as a database leak — treat it with the same seriousness.
- Automate tenant-wide migrations from day one. Running schema changes across hundreds of databases manually is not sustainable, and skipping one tenant silently is a production incident waiting to happen.
- Write isolation tests. If you're not explicitly testing that Tenant A cannot see Tenant B's data, you're relying on hope instead of verification.
