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.
 
        
        
        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:
- Use PgBouncer or ProxySQL for connection pooling
- Implement aggressive connection timeout settings
- 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:
- Create databases for each tenant
- Copy tenant data to new databases
- Update tenant records with database names
- Switch connection handling middleware
- Verify data integrity
- 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
 
            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
Related Articles
 
                                                        Setting Up Laravel 10 with Vue3 and Vuetify3
A complete guide to seamlessly integrating Vue3 and Vuetify3 into Laravel 10 usi...
 
                                                        Effortlessly Dockerize Your Laravel & Vue Application: A Step-by-Step Guide
Unleash the Full Potential of Laravel 10 with Vue 3 and Vuetify 3 in a Dockerize...
 
                                                        Mastering Design Patterns in Laravel
Unraveling the Secrets Behind Laravel’s Architectural Mastery