Building Multi-tenant SaaS Applications

Reverend Philip Dec 20, 2025 7 min read

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

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:

┌─────────────────────────┐
│     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:

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:

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

acme.myapp.com → Tenant: ACME
globex.myapp.com → Tenant: Globex
// 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);
    }
}

Domain-Based

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

Path-Based

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

Header/Token-Based (APIs)

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

Laravel Implementation

Global Scopes

Automatically filter queries by tenant:

// 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

Tenant Service

// 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);

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();
    }
}

Database Patterns

Migrations for Multi-Tenant

// 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']);
        });
    }
}

Running Tenant 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',
        ]);
    });
});

Queues and Jobs

Tenant-Aware Jobs

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);
    }
}

Queue Isolation

// 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

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}";
    }
}

Cache Isolation

// 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

File Storage

Tenant-Scoped Storage

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);
    }
}

Testing Multi-Tenant Applications

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));
    }
}

Cross-Tenant Testing

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);
}

Security Considerations

Preventing Data Leaks

// 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;
}

Super Admin Access

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);
            }
        });
    }
}

Packages

Stancl/Tenancy

Full-featured multi-tenancy package:

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:

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

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

Distributed Locking Patterns

Coordinate access to shared resources across services. Implement distributed locks with Redis, ZooKeeper, and databases.

Jan 16, 2026

Need help with your project?

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