Building Multi-tenant SaaS Applications

Philip Rehberger Dec 20, 2025 11 min read

Architect multi-tenant systems that scale. Compare tenant isolation strategies, database patterns, and customization approaches.

Building Multi-tenant SaaS Applications

Multi-tenancy allows a single application instance to serve multiple customers (tenants) while keeping their data isolated. This architecture is fundamental to SaaS applications. This guide covers multi-tenancy patterns and implementation strategies in Laravel.

Multi-Tenancy Models

Single Database, Shared Tables

All tenants share tables with a tenant_id column:

This is the simplest approach, where every tenant's data lives in the same tables, distinguished only by a foreign key. It's easy to implement but requires careful attention to query scoping.

┌─────────────────────────┐
│     users table         │
├─────────────────────────┤
│ id │ tenant_id │ name   │
├────┼───────────┼────────┤
│ 1  │ 1         │ Alice  │
│ 2  │ 1         │ Bob    │
│ 3  │ 2         │ Carol  │
└────┴───────────┴────────┘

Pros: Simple, efficient resource use Cons: Risk of data leaks, harder compliance

Single Database, Separate Schemas

Each tenant has their own schema/prefix:

Schema-based isolation provides stronger boundaries while keeping all data in one database. Each tenant gets their own set of tables within the same database.

tenant_1.users
tenant_1.orders
tenant_2.users
tenant_2.orders

Pros: Better isolation, easier backups Cons: More complex migrations, schema management

Separate Databases

Each tenant has their own database:

Complete database separation offers the strongest isolation, often required for compliance with regulations like HIPAA or SOC 2.

tenant_1_db → users, orders
tenant_2_db → users, orders

Pros: Complete isolation, easy compliance Cons: More infrastructure, harder queries across tenants

Tenant Identification

Subdomain-Based

Subdomain identification is popular because it provides a clear visual cue to users about which tenant they're accessing. Each customer gets their own subdomain.

acme.myapp.com → Tenant: ACME
globex.myapp.com → Tenant: Globex

The middleware extracts the subdomain and resolves it to a tenant record. This runs early in the request lifecycle to establish tenant context.

// Middleware
class IdentifyTenant
{
    public function handle(Request $request, Closure $next): Response
    {
        $subdomain = explode('.', $request->getHost())[0];

        $tenant = Tenant::where('subdomain', $subdomain)->first();

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

        app()->instance(Tenant::class, $tenant);
        config(['database.default' => "tenant_{$tenant->id}"]);

        return $next($request);
    }
}

The app()->instance() call makes the tenant available throughout the request via dependency injection, while the config change switches the default database connection.

Domain-Based

Custom domains are common for enterprise customers who want their own branding. This requires more DNS configuration but provides a whitelabel experience.

acme.com → Tenant: ACME
globex.com → Tenant: Globex

Path-Based

Path-based tenancy works well for APIs and simpler applications without subdomain requirements. It's easier to set up but less visually distinct.

myapp.com/acme/* → Tenant: ACME
myapp.com/globex/* → Tenant: Globex

Header/Token-Based (APIs)

For APIs, tenant identification often comes from headers or JWT claims. This is common when building APIs for mobile apps or third-party integrations.

X-Tenant-ID: acme
Authorization: Bearer <token_with_tenant_claim>

Laravel Implementation

Global Scopes

Automatically filter queries by tenant:

Global scopes ensure that every query is automatically filtered by tenant, preventing accidental data leaks. This is the foundation of shared-table multi-tenancy and requires no changes to your existing queries.

// app/Models/Traits/BelongsToTenant.php
trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $builder) {
            if ($tenant = app(Tenant::class)) {
                $builder->where('tenant_id', $tenant->id);
            }
        });

        static::creating(function (Model $model) {
            if ($tenant = app(Tenant::class)) {
                $model->tenant_id = $tenant->id;
            }
        });
    }

    public function tenant(): BelongsTo
    {
        return $this->belongsTo(Tenant::class);
    }
}

// Usage
class Project extends Model
{
    use BelongsToTenant;
}

// Queries automatically scoped
$projects = Project::all(); // Only current tenant's projects

The creating event ensures new records are automatically assigned to the current tenant. You never have to remember to set tenant_id manually.

Tenant Service

A dedicated service class provides a clean API for tenant management and configuration. It centralizes all tenant-related concerns in one place.

// app/Services/TenantService.php
class TenantService
{
    private ?Tenant $current = null;

    public function set(Tenant $tenant): void
    {
        $this->current = $tenant;

        // Set database connection if using separate databases
        $this->configureDatabaseConnection($tenant);

        // Set filesystem disk
        config(['filesystems.disks.tenant' => [
            'driver' => 'local',
            'root' => storage_path("app/tenants/{$tenant->id}"),
        ]]);

        // Set cache prefix
        config(['cache.prefix' => "tenant_{$tenant->id}"]);
    }

    public function current(): ?Tenant
    {
        return $this->current;
    }

    private function configureDatabaseConnection(Tenant $tenant): void
    {
        config([
            "database.connections.tenant" => [
                'driver' => 'mysql',
                'host' => $tenant->db_host,
                'database' => $tenant->db_name,
                'username' => $tenant->db_user,
                'password' => $tenant->db_password,
            ]
        ]);

        DB::purge('tenant');
        DB::reconnect('tenant');
    }
}

// Service Provider binding
$this->app->singleton(TenantService::class);

Registering as a singleton ensures the same tenant context is maintained throughout the request lifecycle.

Middleware Stack

The initialization middleware ties everything together, identifying the tenant and configuring the application accordingly. It runs early in the middleware stack.

// app/Http/Middleware/InitializeTenancy.php
class InitializeTenancy
{
    public function __construct(
        private TenantService $tenantService
    ) {}

    public function handle(Request $request, Closure $next): Response
    {
        $tenant = $this->identifyTenant($request);

        if (!$tenant) {
            return redirect()->route('tenant.not-found');
        }

        $this->tenantService->set($tenant);

        return $next($request);
    }

    private function identifyTenant(Request $request): ?Tenant
    {
        // Subdomain identification
        $host = $request->getHost();
        $subdomain = explode('.', $host)[0];

        if ($subdomain === 'www' || $subdomain === config('app.domain')) {
            return null;
        }

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

The is_active check allows you to disable tenants without deleting their data, useful for suspended accounts or migration scenarios.

Database Patterns

Migrations for Multi-Tenant

Separate your migrations into central (shared across all tenants) and tenant-specific categories. This keeps your migration structure organized and clear.

// Central database migrations (tenants table, users, etc.)
// database/migrations/

// Tenant-specific migrations
// database/migrations/tenant/

class CreateProjectsTable extends Migration
{
    public function up(): void
    {
        Schema::create('projects', function (Blueprint $table) {
            $table->id();
            $table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
            $table->string('name');
            $table->timestamps();

            $table->index(['tenant_id', 'created_at']);
        });
    }
}

The composite index on tenant_id and created_at optimizes the most common query pattern: listing a tenant's records in chronological order.

Running Tenant Migrations

For separate database architectures, you need to run migrations against each tenant's database. This command iterates through all tenants and applies migrations.

// Artisan command for tenant migrations
Artisan::command('tenants:migrate', function () {
    Tenant::all()->each(function ($tenant) {
        $this->info("Migrating tenant: {$tenant->name}");

        Artisan::call('migrate', [
            '--database' => "tenant_{$tenant->id}",
            '--path' => 'database/migrations/tenant',
        ]);
    });
});

In production, consider running migrations asynchronously or during off-peak hours to avoid impacting tenant performance.

Queues and Jobs

Tenant-Aware Jobs

Background jobs need tenant context too. Serialize the tenant with the job and restore it when processing to ensure database queries are properly scoped.

class ProcessTenantReport implements ShouldQueue
{
    use SerializesModels;

    public function __construct(
        public Tenant $tenant,
        public Report $report
    ) {}

    public function handle(TenantService $tenantService): void
    {
        // Set tenant context for job
        $tenantService->set($this->tenant);

        // Now all queries are scoped to tenant
        $data = Project::where('status', 'active')->get();
        $this->generateReport($data);
    }
}

Because SerializesModels stores the tenant's ID rather than the full object, the job will use fresh tenant data when it runs.

Queue Isolation

For stronger isolation, route tenant jobs to dedicated queues. This prevents one tenant's workload from impacting others and provides better resource control.

// Dispatch to tenant-specific queue
ProcessTenantReport::dispatch($tenant, $report)
    ->onQueue("tenant_{$tenant->id}");

// Or use connection per tenant
ProcessTenantReport::dispatch($tenant, $report)
    ->onConnection("tenant_{$tenant->id}_redis");

Caching Strategies

Tenant-Prefixed Cache

A cache manager wrapper ensures each tenant's cached data stays separate. This prevents data leakage through the cache layer.

class TenantCacheManager
{
    public function __construct(
        private TenantService $tenantService
    ) {}

    public function get(string $key, mixed $default = null): mixed
    {
        return Cache::get($this->prefixKey($key), $default);
    }

    public function put(string $key, mixed $value, int $ttl = 3600): void
    {
        Cache::put($this->prefixKey($key), $value, $ttl);
    }

    public function forget(string $key): void
    {
        Cache::forget($this->prefixKey($key));
    }

    private function prefixKey(string $key): string
    {
        $tenantId = $this->tenantService->current()?->id ?? 'global';
        return "tenant_{$tenantId}:{$key}";
    }
}

Using the tenant ID as a prefix is simpler than managing separate cache stores per tenant.

Cache Isolation

For complete isolation, configure separate cache stores and switch between them based on tenant context. This provides stronger guarantees but requires more configuration.

// config/cache.php
'stores' => [
    'tenant' => [
        'driver' => 'redis',
        'connection' => 'tenant',
        'prefix' => '', // Set dynamically
    ],
],

// In TenantService::set()
config(['cache.stores.tenant.prefix' => "tenant_{$tenant->id}"]);
Cache::store('tenant')->flush(); // Clear on tenant switch

Be cautious with flush() during tenant switches - in a web request context, this could cause issues. It's more appropriate during testing or maintenance.

File Storage

Tenant-Scoped Storage

Keep each tenant's files in isolated directories or with isolated prefixes in object storage. This ensures tenants cannot access each other's files.

class TenantStorageService
{
    public function disk(): Filesystem
    {
        $tenant = app(TenantService::class)->current();

        return Storage::build([
            'driver' => 's3',
            'bucket' => config('filesystems.disks.s3.bucket'),
            'root' => "tenants/{$tenant->id}",
        ]);
    }

    public function store(UploadedFile $file, string $path): string
    {
        return $this->disk()->putFile($path, $file);
    }

    public function url(string $path): string
    {
        return $this->disk()->url($path);
    }
}

Using a root prefix in S3 provides logical separation while keeping all tenants in one bucket for simpler management.

Testing Multi-Tenant Applications

Test Setup

Create helper traits to make tenant context easy to establish in tests. This reduces boilerplate and ensures consistent test setup.

trait InteractsWithTenants
{
    protected Tenant $tenant;

    protected function setUpTenant(): void
    {
        $this->tenant = Tenant::factory()->create();
        app(TenantService::class)->set($this->tenant);
    }

    protected function actingAsTenant(Tenant $tenant): self
    {
        app(TenantService::class)->set($tenant);
        return $this;
    }
}

class ProjectTest extends TestCase
{
    use InteractsWithTenants;

    protected function setUp(): void
    {
        parent::setUp();
        $this->setUpTenant();
    }

    public function test_projects_are_scoped_to_tenant(): void
    {
        $tenantProject = Project::factory()->create();
        $otherTenant = Tenant::factory()->create();
        $otherProject = Project::factory()->create(['tenant_id' => $otherTenant->id]);

        $projects = Project::all();

        $this->assertCount(1, $projects);
        $this->assertTrue($projects->contains($tenantProject));
        $this->assertFalse($projects->contains($otherProject));
    }
}

This test verifies the core promise of multi-tenancy: one tenant cannot see another's data.

Cross-Tenant Testing

Test scenarios where admins or super-users need to access data across tenants. These tests ensure administrative features work correctly.

public function test_admin_can_access_all_tenants(): void
{
    $tenant1 = Tenant::factory()->create();
    $tenant2 = Tenant::factory()->create();

    Project::factory()->for($tenant1)->count(3)->create();
    Project::factory()->for($tenant2)->count(2)->create();

    // Admin bypasses tenant scope
    $admin = User::factory()->admin()->create();
    $this->actingAs($admin);

    $projects = Project::withoutGlobalScope('tenant')->get();

    $this->assertCount(5, $projects);
}

The withoutGlobalScope method explicitly bypasses tenant filtering, which should only happen in specific admin contexts.

Security Considerations

Preventing Data Leaks

Defense in depth means checking tenant ownership at multiple levels, not just relying on global scopes. Add explicit authorization checks.

// Always validate tenant ownership
public function show(Project $project): ProjectResource
{
    // Route model binding already uses global scope
    // But add explicit check for defense in depth
    $this->authorize('view', $project);

    return new ProjectResource($project);
}

// Policy
public function view(User $user, Project $project): bool
{
    return $user->tenant_id === $project->tenant_id;
}

The policy check catches cases where global scopes might be bypassed accidentally or through code changes.

Super Admin Access

Super admins often need to impersonate tenants or access data across all tenants. Build this capability carefully with proper auditing.

trait BelongsToTenant
{
    protected static function bootBelongsToTenant(): void
    {
        static::addGlobalScope('tenant', function (Builder $builder) {
            $tenant = app(TenantService::class)->current();

            // Skip scope for super admins
            if (auth()->user()?->isSuperAdmin()) {
                return;
            }

            if ($tenant) {
                $builder->where('tenant_id', $tenant->id);
            }
        });
    }
}

Super admin bypass should be logged and audited. Consider requiring explicit opt-in rather than automatic bypass.

Packages

Stancl/Tenancy

Full-featured multi-tenancy package:

Stancl's tenancy package handles most multi-tenancy concerns out of the box, including automatic database switching, queue awareness, and event hooks. It's a great choice for rapid development.

composer require stancl/tenancy
php artisan tenancy:install
// Automatic tenant identification
// Automatic database switching
// Queue tenant awareness
// Event hooks for tenant lifecycle

Spatie/Laravel-Multitenancy

Lightweight, flexible approach:

Spatie's package provides building blocks rather than a complete solution, giving you more control over implementation details. It's ideal when you need customization.

composer require spatie/laravel-multitenancy
// Custom tenant identification
// Task-based tenant switching
// Full control over implementation

Choose based on your needs: Stancl for a batteries-included approach, Spatie for maximum flexibility.

Best Practices

  1. Defense in depth - Multiple layers of tenant isolation
  2. Test isolation - Verify data doesn't leak between tenants
  3. Audit logging - Track cross-tenant access attempts
  4. Performance monitoring - Per-tenant metrics and limits
  5. Backup strategy - Tenant-specific or isolated backups
  6. Compliance ready - Easy data export/deletion per tenant

Conclusion

Multi-tenancy enables efficient SaaS applications serving multiple customers from a single codebase. Choose your isolation level based on compliance requirements and scaling needs. Start with single-database shared tables for simplicity, move to separate databases for strict isolation. Always implement defense in depth with global scopes, policies, and explicit tenant checks throughout your application.

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.