Skip to main content

Multi-Tenant SaaS Architecture with Laravel

00:07:23:10

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.

php
// 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.

php
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.

php
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.

php
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:

php
// 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.

php
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.

php
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.

php
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.

php
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

  1. Choose your database strategy based on compliance requirements first, performance second. In fintech, regulators often dictate your architecture more than your engineers do.
  2. Build a tenant resolution middleware that runs before everything else. Subdomain-based resolution is the cleanest pattern for most SaaS products.
  3. Never forget queued jobs. Jobs execute outside the request lifecycle, so you must explicitly serialize and restore the tenant context.
  4. 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.
  5. 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.
  6. 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.

Want to discuss this topic or work together? Get in touch.

Contact me

Related Articles

agile-leadership-in-fintech-teams

api-design-for-fintech-platforms

building-scalable-fintech-crms-with-laravel