11 min read

Laravel Multi-Tenancy: Database vs Subdomain vs Path Routing Strategies

Compare Laravel multi-tenancy approaches: database isolation, subdomain routing, and path routing with Spatie's package for optimal SaaS architecture.

Laravel Multi-tenancy SaaS Spatie PHP
Laravel Multi-Tenancy: Database vs Subdomain vs Path Routing Strategies

Building a SaaS product? You'll hit the multi-tenancy decision faster than you expect. I learned this the hard way when scaling StudyLab from 10 to 500+ schools. One client's data mixing with another's isn't just embarrassing, it's a compliance nightmare and potential lawsuit.

Multi-tenancy in Laravel means serving multiple customers (tenants) from a single application while keeping their data completely isolated. Think Shopify stores, where thousands of shops run on shared infrastructure but never see each other's orders. The architecture you pick now will determine how easily you scale later.

Here's the thing: there's no "best" approach. I've used all three patterns across different projects, and each time the decision came down to specific requirements. Let me show you what I've learned.

What Is Multi-Tenancy in Laravel?

Multi-tenancy is an architecture where a single application instance serves multiple customers. Each customer (tenant) gets their own isolated environment, but they're all running on shared infrastructure.

The key challenge? Data isolation. You need absolute certainty that Tenant A can never access Tenant B's data. Even a single breach destroys trust and can shut down your business.

Laravel doesn't have multi-tenancy built in, but the ecosystem offers solid solutions. The most popular is Spatie's laravel-multitenancy package, which handles the heavy lifting. However, before you install anything, you need to understand which isolation strategy fits your needs.

The Three Multi-Tenancy Approaches

1. Database-Per-Tenant (Strong Isolation)

Each tenant gets their own database. Complete separation at the infrastructure level.

When I use this: High-security requirements, clients who demand dedicated resources, or when tenants have wildly different database needs.

I implemented this for a healthcare SaaS where HIPAA compliance required absolute database isolation. Each clinic had a separate MySQL database, and there was zero chance of data leakage.

Pros:

  • Maximum security and isolation
  • Easy to backup/restore individual tenants
  • Can customize database schema per tenant
  • Simple to migrate tenants between servers
  • Performance issues in one database don't affect others

Cons:

  • High resource overhead (one database = one connection pool)
  • Complex migrations (run against every database)
  • Expensive at scale (100 tenants = 100 databases)
  • Cross-tenant reporting becomes a nightmare
  • Database connection limits hit faster

2. Subdomain-Based Tenancy (Shared Database)

All tenants share one database, but each gets their own subdomain. You identify tenants by subdomain and filter all queries by tenant_id.

When I use this: Most SaaS products where tenants don't need dedicated infrastructure but want branded URLs.

This is my go-to for 80% of projects. StudyLab uses this approach, each school gets schoolname.studylab.io, and we filter queries by school_id. Works great up to thousands of tenants.

Pros:

  • Cost-effective (single database)
  • Easy cross-tenant reporting
  • Simpler infrastructure
  • Fast tenant provisioning
  • Professional appearance with custom domains

Cons:

  • Must be meticulous with query scoping
  • One performance issue affects everyone
  • Harder to provide tenant-specific customization
  • Wildcard SSL certificate required
  • Database size grows continuously

3. Path-Based Tenancy (Shared Everything)

Tenants are identified by URL path: yoursaas.com/tenant1, yoursaas.com/tenant2. Same database, same domain.

When I use this: Internal tools, B2B applications where branding doesn't matter, or when subdomain management is too complex.

I built an automation dashboard using this approach because clients didn't care about custom URLs. They wanted functionality, not branding.

Pros:

  • Simplest to implement
  • No SSL certificate complexity
  • Single codebase, single database
  • Easy local development
  • Cheapest infrastructure

Cons:

  • Looks less professional
  • No custom domain support
  • Same data isolation challenges as subdomains
  • Can't easily separate tenants later
  • Limited scaling options

Implementing Database-Per-Tenant in Laravel

Let's start with the most isolated approach. You'll need a central database that tracks tenants, then dynamic connection switching.

Step 1: Set Up Central Database

Create a migration for your tenants table:

// database/migrations/create_tenants_table.php
Schema::create('tenants', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('domain')->unique();
    $table->string('database')->unique();
    $table->timestamps();
});

Step 2: Configure Dynamic Database Connections

Update your config/database.php:

'tenant' => [
    'driver' => 'mysql',
    'host' => env('DB_HOST', '127.0.0.1'),
    'port' => env('DB_PORT', '3306'),
    'database' => null, // Will be set dynamically
    'username' => env('DB_USERNAME', 'forge'),
    'password' => env('DB_PASSWORD', ''),
    'charset' => 'utf8mb4',
    'collation' => 'utf8mb4_unicode_ci',
    'prefix' => '',
    'strict' => true,
],

Step 3: Create Tenant Identification Middleware

// app/Http/Middleware/IdentifyTenant.php
namespace App\Http\Middleware;

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

class IdentifyTenant
{
    public function handle($request, Closure $next)
    {
        $domain = $request->getHost();
        
        // Look up tenant in central database
        $tenant = Tenant::where('domain', $domain)->firstOrFail();
        
        // Switch to tenant's database
        Config::set('database.connections.tenant.database', $tenant->database);
        DB::purge('tenant');
        DB::reconnect('tenant');
        DB::setDefaultConnection('tenant');
        
        // Store tenant in request for later use
        $request->attributes->set('tenant', $tenant);
        
        return $next($request);
    }
}

Step 4: Register Middleware

Add to app/Http/Kernel.php:

protected $middlewareGroups = [
    'web' => [
        // ... other middleware
        \App\Http\Middleware\IdentifyTenant::class,
    ],
];

Step 5: Create Tenant Provisioning Command

// app/Console/Commands/CreateTenant.php
namespace App\Console\Commands;

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

class CreateTenant extends Command
{
    protected $signature = 'tenant:create {name} {domain}';
    
    public function handle()
    {
        $name = $this->argument('name');
        $domain = $this->argument('domain');
        $database = 'tenant_' . str_replace(['.', '-'], '_', $domain);
        
        // Create database
        DB::statement("CREATE DATABASE `{$database}`");
        
        // Create tenant record
        $tenant = Tenant::create([
            'name' => $name,
            'domain' => $domain,
            'database' => $database,
        ]);
        
        // Run migrations on new database
        $this->call('migrate', [
            '--database' => 'tenant',
            '--path' => 'database/migrations/tenant',
        ]);
        
        $this->info("Tenant {$name} created successfully!");
    }
}

Watch out: Database creation requires elevated MySQL privileges. In production, I automate this through infrastructure-as-code or use a database management service that supports programmatic database creation.

Implementing Subdomain-Based Tenancy

This is simpler because you're working with a shared database. I'll show you the approach I use in most projects.

Step 1: Install Spatie Multi-Tenancy

composer require spatie/laravel-multitenancy
php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider"
php artisan migrate

Step 2: Create Tenant Model

// app/Models/Tenant.php
namespace App\Models;

use Spatie\Multitenancy\Models\Tenant as BaseTenant;

class Tenant extends BaseTenant
{
    protected $fillable = ['name', 'domain'];
    
    public function users()
    {
        return $this->hasMany(User::class);
    }
}

Step 3: Add Tenant Foreign Key to Models

// database/migrations/add_tenant_id_to_users.php
Schema::table('users', function (Blueprint $table) {
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
});

Update your User model:

// app/Models/User.php
use Spatie\Multitenancy\Models\Concerns\UsesTenantConnection;
use Spatie\Multitenancy\Models\Concerns\UsesLandlordConnection;

class User extends Authenticatable
{
    protected $fillable = ['tenant_id', 'name', 'email', 'password'];
    
    public function tenant()
    {
        return $this->belongsTo(Tenant::class);
    }
}

Step 4: Apply Global Scopes

This is critical. Every query must automatically filter by tenant.

// app/Models/User.php (add to boot method)
protected static function booted()
{
    static::addGlobalScope('tenant', function ($query) {
        if (auth()->check()) {
            $query->where('tenant_id', auth()->user()->tenant_id);
        }
    });
}

Better approach: Use Spatie's built-in scope:

// app/Models/User.php
use Spatie\Multitenancy\Models\Concerns\UsesLandlordConnection;

class User extends Authenticatable
{
    use UsesLandlordConnection;
    
    public function tenant()
    {
        return $this->belongsTo(Tenant::class);
    }
}

Step 5: Configure Subdomain Routing

Update your .env:

APP_URL=http://localhost
SESSION_DOMAIN=.yoursaas.test

In config/multitenancy.php:

'tenant_finder' => \Spatie\Multitenancy\TenantFinder\DomainTenantFinder::class,

Step 6: Wildcard DNS Setup

For local development, update /etc/hosts:

127.0.0.1 yoursaas.test
127.0.0.1 tenant1.yoursaas.test
127.0.0.1 tenant2.yoursaas.test

For production, create a wildcard DNS A record: *.yoursaas.com → your server IP.

You'll also need a wildcard SSL certificate. Let's Encrypt supports this with DNS verification:

certbot certonly --dns-cloudflare --dns-cloudflare-credentials ~/.secrets/cloudflare.ini -d yoursaas.com -d *.yoursaas.com

Implementing Path-Based Tenancy

The simplest approach. Perfect for internal tools or when you don't need branded subdomains.

Step 1: Define Tenant Routes

// routes/web.php
Route::prefix('{tenant}')->group(function () {
    Route::get('/', [DashboardController::class, 'index']);
    Route::resource('users', UserController::class);
    Route::resource('projects', ProjectController::class);
});

Step 2: Create Tenant Middleware

// app/Http/Middleware/IdentifyTenantByPath.php
namespace App\Http\Middleware;

use Closure;
use App\Models\Tenant;

class IdentifyTenantByPath
{
    public function handle($request, Closure $next)
    {
        $tenantSlug = $request->route('tenant');
        
        $tenant = Tenant::where('slug', $tenantSlug)->firstOrFail();
        
        app()->instance('tenant', $tenant);
        
        return $next($request);
    }
}

Step 3: Apply Global Scope

Same as subdomain approach, filter all queries by tenant_id:

// app/Providers/AppServiceProvider.php
public function boot()
{
    Builder::macro('forCurrentTenant', function () {
        if ($tenant = app('tenant')) {
            return $this->where('tenant_id', $tenant->id);
        }
        return $this;
    });
}

Usage:

// Automatically scoped to current tenant
User::forCurrentTenant()->get();
Project::forCurrentTenant()->where('status', 'active')->get();

Query Scoping: The Make-or-Break Detail

Here's where most developers screw up multi-tenancy. One missed where('tenant_id', ...) and you've got a data leak.

Automatic Scoping with Traits

I create a trait for all tenant-scoped models:

// app/Models/Concerns/BelongsToTenant.php
namespace App\Models\Concerns;

use App\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;

trait BelongsToTenant
{
    protected static function bootBelongsToTenant()
    {
        static::addGlobalScope('tenant', function (Builder $builder) {
            if ($tenant = app('tenant')) {
                $builder->where('tenant_id', $tenant->id);
            }
        });
        
        static::creating(function ($model) {
            if ($tenant = app('tenant')) {
                $model->tenant_id = $tenant->id;
            }
        });
    }
    
    public function tenant()
    {
        return $this->belongsTo(Tenant::class);
    }
}

Apply to models:

// app/Models/Project.php
class Project extends Model
{
    use BelongsToTenant;
    
    // tenant_id is automatically applied to all queries
}

Bypassing Scopes When Needed

Sometimes you need cross-tenant queries (admin dashboards, reporting):

// Remove tenant scope for this query
User::withoutGlobalScope('tenant')->get();

// Or temporarily disable
User::withoutTenantScope()->where('email', 'admin@example.com')->first();

Security tip: Never allow tenant scope bypass in regular controllers. Create separate admin controllers with explicit authentication checks.

Database Migrations Across Tenants

Running migrations differs by approach.

Database-Per-Tenant Migrations

You need to migrate every tenant database:

// app/Console/Commands/MigrateTenants.php
public function handle()
{
    $tenants = Tenant::all();
    
    foreach ($tenants as $tenant) {
        $this->info("Migrating {$tenant->name}...");
        
        Config::set('database.connections.tenant.database', $tenant->database);
        DB::purge('tenant');
        
        $this->call('migrate', [
            '--database' => 'tenant',
            '--path' => 'database/migrations/tenant',
        ]);
    }
}

I run this in CI/CD pipelines during deployments. Takes longer as you scale, but it's manageable with parallel processing.

Shared Database Migrations

Standard Laravel migrations work fine. Just remember to add tenant_id to every table:

Schema::table('orders', function (Blueprint $table) {
    $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
    $table->index(['tenant_id', 'created_at']); // Important for performance
});

Performance Considerations

Multi-tenancy adds complexity that affects performance.

Database Indexing

Every tenant-scoped query needs proper indexes:

// Always index tenant_id with frequently queried columns
$table->index(['tenant_id', 'status']);
$table->index(['tenant_id', 'user_id']);
$table->index(['tenant_id', 'created_at']);

I learned this the hard way when StudyLab slowed to a crawl at 200 schools. Adding composite indexes cut query time from 2s to 50ms.

Connection Pooling

With database-per-tenant, connection pools fill up fast. Solutions:

  1. Use PgBouncer or ProxySQL for connection pooling
  2. Implement aggressive connection timeout settings
  3. Consider switching to shared database once you hit 50+ tenants

Caching Strategies

Cache keys must include tenant identifier:

// Bad - cache collision between tenants
Cache::remember('users', 3600, fn() => User::all());

// Good - tenant-specific cache
Cache::remember("tenant_{$tenant->id}_users", 3600, fn() => User::all());

Spatie's package handles this automatically if you use their caching layer.

Common Mistakes to Avoid

Mistake 1: Forgetting Tenant Scope on Eager Loads

// This bypasses tenant scope on posts!
$user = User::with('posts')->find($id);

// Make sure related models also use BelongsToTenant trait

Mistake 2: Queue Jobs Without Tenant Context

Jobs run outside request context, so tenant isn't automatically available:

// app/Jobs/ProcessOrder.php
class ProcessOrder implements ShouldQueue
{
    public $tenantId;
    
    public function __construct($orderId, $tenantId)
    {
        $this->orderId = $orderId;
        $this->tenantId = $tenantId;
    }
    
    public function handle()
    {
        $tenant = Tenant::find($this->tenantId);
        app()->instance('tenant', $tenant);
        
        // Now your queries are properly scoped
        $order = Order::find($this->orderId);
    }
}

Mistake 3: Not Testing Cross-Tenant Isolation

Write tests that verify data isolation:

public function test_tenant_cannot_access_other_tenant_data()
{
    $tenant1 = Tenant::factory()->create();
    $tenant2 = Tenant::factory()->create();
    
    app()->instance('tenant', $tenant1);
    $user1 = User::factory()->create();
    
    app()->instance('tenant', $tenant2);
    $users = User::all();
    
    $this->assertCount(0, $users); // Should not see tenant1's user
}

I run these tests on every deployment. One failure shuts down the pipeline.

Mistake 4: Not Planning for Tenant Data Exports

GDPR requires you provide tenant data exports. Plan for this early:

// app/Console/Commands/ExportTenantData.php
public function handle()
{
    $tenant = Tenant::where('domain', $this->argument('domain'))->first();
    app()->instance('tenant', $tenant);
    
    $data = [
        'users' => User::all(),
        'projects' => Project::all(),
        // ... other models
    ];
    
    Storage::put("exports/{$tenant->id}.json", json_encode($data));
}

Mistake 5: Using UUID Primary Keys Without Planning

UUIDs prevent auto-increment ID exposure between tenants, but they come with tradeoffs:

// Larger index size, slower joins
$table->uuid('id')->primary();

I use UUIDs only when tenant-facing URLs include IDs. For internal references, auto-increment is faster.

When to Use Each Approach

After building multiple SaaS products, here's my decision framework:

Use Database-Per-Tenant when:

  • Healthcare, finance, or other regulated industries
  • Clients explicitly pay for dedicated resources
  • You need tenant-specific database customization
  • Compliance requires physical data separation
  • You have fewer than 50 tenants

Use Subdomain-Based Tenancy when:

  • Building standard B2B SaaS
  • Branding matters to customers
  • You expect 50-5000 tenants
  • Cost optimization is important
  • You need cross-tenant analytics

Use Path-Based Tenancy when:

  • Internal tools or admin dashboards
  • Branding doesn't matter
  • Simplicity is the priority
  • You're prototyping or building an MVP
  • Budget is extremely limited

Migration Paths Between Approaches

You're not locked into your initial choice. I've migrated projects between all three patterns.

From Path to Subdomain

Easiest migration. Just update routing:

// Before: yoursaas.com/tenant1
Route::prefix('{tenant}')->group(...)

// After: tenant1.yoursaas.com
Route::domain('{tenant}.yoursaas.com')->group(...)

Add DNS records and SSL certificates. No database changes needed.

From Subdomain to Database-Per-Tenant

More complex. You need to:

  1. Create databases for each tenant
  2. Copy tenant data to new databases
  3. Update tenant records with database names
  4. Switch connection handling middleware
  5. Verify data integrity
  6. Gradually migrate tenants (not all at once)

This took me two weeks for a 100-tenant application, running migrations during low-traffic windows.

From Database-Per-Tenant to Subdomain

Reverse process, but includes data consolidation. I built a migration script:

foreach ($tenants as $tenant) {
    DB::connection('tenant')->table('users')
        ->chunkById(100, function ($users) use ($tenant) {
            foreach ($users as $user) {
                DB::table('users')->insert([
                    ...(array)$user,
                    'tenant_id' => $tenant->id,
                ]);
            }
        });
}

Risky at scale. Test extensively before attempting in production.

Conclusion

Multi-tenancy isn't one-size-fits-all. I've used database-per-tenant for healthcare apps, subdomain-based for standard SaaS, and path-based for internal tools. Each choice was right for its context.

Start with subdomain-based tenancy for most projects. It balances cost, complexity, and scalability. You can always migrate to database-per-tenant later if needed.

The critical part? Test your tenant isolation religiously. Write automated tests, conduct security audits, and never assume your scoping is bulletproof. One data leak can destroy your business.

Next steps: Install Spatie's multitenancy package, scaffold your tenant model, and build a simple proof-of-concept. Get the isolation working correctly before adding features.

Need help implementing multi-tenancy in your Laravel application? I've built this architecture across multiple SaaS products and can help you avoid the costly mistakes I made. Let's work together.


Need Help With Your Laravel Project?

I specialize in building custom Laravel applications, process automation, and SaaS development. Whether you need to eliminate repetitive tasks or build something from scratch, let's discuss your project.

⚡ Currently available for 2-3 new projects

Hafiz Riaz

About Hafiz Riaz

Full Stack Developer from Turin, Italy. I build web applications with Laravel and Vue.js, and automate business processes. Creator of ReplyGenius, StudyLab, and other SaaS products.

View Portfolio →

Get web development tips via email

Join 50+ developers • No spam • Unsubscribe anytime