DEV Community

Cover image for Absolute Isolation: Database-per-Tenant in Laravel 🗄️
Prajapati Paresh
Prajapati Paresh

Posted on Originally published at smarttechdevs.in

Absolute Isolation: Database-per-Tenant in Laravel 🗄️

The Multi-Tenant Security Dilemma

When architecting a B2B Software-as-a-Service (SaaS) platform, your most critical foundational decision is how to handle multi-tenancy. The standard approach is the "Shared Database, Shared Schema" model. You add a tenant_id column to every single table (Users, Invoices, Projects) and rely on Laravel Global Scopes or PostgreSQL Row-Level Security (RLS) to filter queries. This is highly cost-effective and easy to maintain.

However, when you start signing enterprise clients—banks, healthcare providers, or government agencies—the shared database model instantly fails compliance audits. These enterprise clients require absolute mathematical certainty that their data cannot physically bleed into another client's dashboard due to a developer's missing WHERE clause. Furthermore, they often require strict data residency laws (e.g., European clients demanding their data physically lives on a Frankfurt server, while US clients are hosted in Ohio), and they frequently demand custom backup and restoration schedules.

At Smart Tech Devs, we satisfy enterprise compliance by architecting the Database-per-Tenant Model. In this architecture, the application codebase is shared, but every single tenant gets their own completely isolated, physically separate database.

The Philosophy of Dynamic Connections

In a Database-per-Tenant architecture, Laravel must dynamically change its database configuration on the fly for every single HTTP request. We achieve this by utilizing a "Landlord" database and multiple "Tenant" databases.

  • The Landlord Database: A central database that contains the global tenants table. It stores the tenant's domain name, their subscription status, and the encrypted credentials needed to access their specific database.
  • The Tenant Database: A completely isolated database containing only that specific tenant's business data (Users, Invoices, etc.). It contains no tenant metadata.

Phase 1: Architecting the Landlord and Middleware

First, we configure Laravel to understand the concept of a "Landlord" connection in config/database.php. This is the default connection the application boots with.

When an HTTP request arrives, we use a global Middleware to inspect the incoming request (usually via the subdomain, e.g., acme.smarttechdevs.in), query the Landlord database to find the tenant, and dynamically rewrite Laravel's default database configuration on the fly.


namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Config;
use App\Models\Landlord\Tenant;

class IdentifyAndSwitchTenant
{
    public function handle(Request $request, Closure $next)
    {
        // 1. Identify the tenant via the request host (subdomain)
        $host = $request->getHost();
        $subdomain = explode('.', $host)[0];

        // 2. Query the Landlord database to find the tenant's connection details
        $tenant = Tenant::where('subdomain', $subdomain)->first();

        if (!$tenant) {
            abort(404, 'Tenant not found.');
        }

        // 3. Dynamically configure a new database connection in memory
        Config::set('database.connections.tenant', [
            'driver' => 'mysql',
            'host' => $tenant->db_host,
            'port' => $tenant->db_port,
            'database' => $tenant->db_name,
            'username' => $tenant->db_username,
            'password' => decrypt($tenant->db_password), // Securely decrypt credentials
            'charset' => 'utf8mb4',
            'collation' => 'utf8mb4_unicode_ci',
            'prefix' => '',
            'strict' => true,
        ]);

        // 4. Force Laravel to use this newly created connection as the default
        DB::setDefaultConnection('tenant');

        // 5. Store the active tenant in the Service Container for global access
        app()->instance('currentTenant', $tenant);

        return $next($request);
    }
}

Phase 2: The Migration Crisis

Dynamically switching connections during an HTTP request is relatively simple. The true architectural nightmare of a Database-per-Tenant setup is DevOps. If you have 500 enterprise clients, you have 500 completely isolated databases. When you write a new migration to add a phone_number column to the users table, you cannot simply run php artisan migrate. You must run that migration 500 times, connecting to 500 different databases sequentially.

To solve this, we must architect a custom Artisan Command that acts as a migration orchestrator.


namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Artisan;
use App\Models\Landlord\Tenant;

class MigrateTenantsCommand extends Command
{
    protected $signature = 'tenants:migrate {--rollback : Rollback the last migration}';
    protected $description = 'Run migrations across all isolated tenant databases.';

    public function handle()
    {
        $this->info("Fetching tenants from the Landlord database...");
        
        // 1. Fetch all active tenants
        $tenants = Tenant::all();

        foreach ($tenants as $tenant) {
            $this->warn("Migrating Tenant: {$tenant->name} ({$tenant->db_name})");

            // 2. Dynamically set the database connection
            Config::set('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'); // Clear cached connection data
            DB::setDefaultConnection('tenant');

            // 3. Execute the migration specifically on this connection
            $command = $this->option('rollback') ? 'migrate:rollback' : 'migrate';
            
            Artisan::call($command, [
                '--database' => 'tenant', // Target our dynamic connection
                '--path' => 'database/migrations/tenant', // Only run tenant-specific migrations
                '--force' => true, // Bypass production prompts
            ]);

            $this->info(Artisan::output());
        }

        $this->info("All tenant databases have been successfully migrated.");
    }
}

Phase 3: Handling Asynchronous Queues

Just like with Row-Level Security, asynchronous queues (Redis) completely break the dynamic tenant context. If User A triggers a "Generate Invoice" background job, the Redis worker will boot up with the default Landlord connection and instantly crash because it doesn't know which database to connect to.

Your queued jobs must be heavily modified to carry their own tenant payload, forcing the worker to manually re-establish the dynamic database connection before executing the business logic.


namespace App\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\DB;
use App\Models\Landlord\Tenant;
use App\Models\Invoice; // Belongs to the Tenant connection

class GenerateInvoicePdf implements ShouldQueue
{
    use Dispatchable, Queueable;

    // We pass the primitive ID of the tenant into the job construct
    public function __construct(
        public readonly int $tenantId, 
        public readonly int $invoiceId
    ) {}

    public function handle()
    {
        // 1. Re-establish the Tenant Connection inside the isolated worker
        $tenant = Tenant::findOrFail($this->tenantId);
        
        Config::set('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::setDefaultConnection('tenant');

        // 2. Safely execute business logic against the correct database
        $invoice = Invoice::findOrFail($this->invoiceId);
        // ... Generate PDF ...
    }
}

The Engineering ROI and Ultimate Compliance

Implementing a Database-per-Tenant architecture vastly increases your DevOps complexity. You must automate database provisioning, manage 500 separate migration lifecycles, and strictly architect your background queues. However, the return on investment is unparalleled when targeting enterprise clients.

You guarantee absolute, physical data isolation. You can host specific databases in specific geographic regions to satisfy GDPR or CCPA data residency laws. If a high-paying enterprise client demands to be restored to a backup from yesterday at 2:00 PM, you can instantly restore their specific database without impacting the data of the other 499 tenants on your platform. This architecture doesn't just improve security; it is a primary sales asset that closes massive enterprise contracts.

Top comments (0)