home / blog / Multi-Tenancy in Laravel: A Complete Arc...
Laravel Jan 15, 2026

Multi-Tenancy in Laravel: A Complete Architecture Guide

Learn how to build scalable multi-tenant applications in Laravel using database-per-tenant and single-database strategies, with practical code examples.

Multi-Tenancy in Laravel: A Complete Architecture Guide

Why Multi-Tenancy?

Multi-tenancy is the architecture pattern where a single application instance serves multiple customers (tenants), each with their own isolated data. Think of it like an apartment building — one building, many units, each with their own lock and key.

If you're building a SaaS product, you'll inevitably face this decision: how do I isolate tenant data? The answer has major implications for security, performance, cost, and complexity.

The Three Strategies

1. Single Database, Shared Schema

Every table has a tenant_id column. This is the simplest approach but requires discipline — forget one where clause and you've got a data leak.

// A global scope handles tenant filtering automatically
class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        if (auth()->check()) {
            $builder->where($model->getTable() . '.tenant_id', auth()->user()->tenant_id);
        }
    }
}

// Apply it via a trait
trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope(new TenantScope);

        static::creating(function (Model $model) {
            if (auth()->check()) {
                $model->tenant_id = auth()->user()->tenant_id;
            }
        });
    }
}

// Usage is invisible to the developer
class Invoice extends Model
{
    use BelongsToTenant;
}

// This query is automatically scoped
Invoice::all(); // SELECT * FROM invoices WHERE tenant_id = 1

Pros: Simple deployment, easy backups, low cost.
Cons: Risk of data leakage, harder to scale individual tenants, noisy neighbor problem.

2. Single Database, Separate Schemas

Each tenant gets their own database schema (or prefix). This gives better isolation without the overhead of multiple databases.

// Dynamically set the table prefix per tenant
class TenantDatabaseManager
{
    public function connect(Tenant $tenant): void
    {
        config(['database.connections.tenant.prefix' => "tenant_{$tenant->id}_"]);
        DB::purge('tenant');
        DB::reconnect('tenant');
    }
}

3. Database Per Tenant

Each tenant gets their own database. Maximum isolation, but highest operational complexity.

// config/database.php — the tenant connection is dynamic
'tenant' => [
    'driver' => 'mysql',
    'database' => '', // Set at runtime
    'host' => env('DB_HOST', '127.0.0.1'),
    // ...
],

// Tenant middleware resolves and connects
class IdentifyTenant
{
    public function handle(Request $request, Closure $next)
    {
        $tenant = Tenant::where('domain', $request->getHost())->firstOrFail();

        config([
            'database.connections.tenant.database' => "tenant_{$tenant->id}",
        ]);

        DB::purge('tenant');
        app()->instance(Tenant::class, $tenant);

        return $next($request);
    }
}

Tenant Resolution Strategies

How do you know which tenant a request belongs to? Common approaches:

  • Subdomain: acme.yourapp.com — clean, professional, easy to parse
  • Path prefix: yourapp.com/acme/dashboard — simpler DNS setup
  • Custom domain: app.acme.com — requires wildcard SSL or per-domain certs
  • Request header: X-Tenant-ID: acme — common for APIs
// Subdomain resolver
class SubdomainTenantResolver implements TenantResolver
{
    public function resolve(Request $request): ?Tenant
    {
        $subdomain = explode('.', $request->getHost())[0];

        return Tenant::where('slug', $subdomain)->first();
    }
}

Migration Management

With database-per-tenant, you need to run migrations across all tenant databases:

// artisan command: php artisan tenants:migrate
class TenantMigrate extends Command
{
    protected $signature = 'tenants:migrate {--tenant=}';

    public function handle(): void
    {
        $tenants = $this->option('tenant')
            ? Tenant::where('id', $this->option('tenant'))->get()
            : Tenant::all();

        $tenants->each(function (Tenant $tenant) {
            $this->info("Migrating tenant: {$tenant->name}");

            config(['database.connections.tenant.database' => "tenant_{$tenant->id}"]);
            DB::purge('tenant');

            Artisan::call('migrate', [
                '--database' => 'tenant',
                '--path' => 'database/migrations/tenant',
                '--force' => true,
            ]);

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

Which Strategy Should You Choose?

Factor Shared Schema Separate Schema Separate DB
Isolation Low Medium High
Complexity Low Medium High
Cost Low Medium High
Tenant Count Thousands Hundreds Tens to Hundreds
Compliance (GDPR) Harder Easier Easiest

Start with the shared schema approach. Move to separate databases only when regulatory requirements or a specific high-value tenant demands it. Premature isolation is a form of over-engineering.

Key Takeaways

  • Use global scopes to enforce tenant isolation automatically
  • Always write tests that verify cross-tenant data cannot leak
  • Consider using packages like stancl/tenancy for database-per-tenant setups
  • Cache tenant resolution — it happens on every single request
  • Plan your migration strategy before you have 100 tenants
back to all posts