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
- Defense in depth - Multiple layers of tenant isolation
- Test isolation - Verify data doesn't leak between tenants
- Audit logging - Track cross-tenant access attempts
- Performance monitoring - Per-tenant metrics and limits
- Backup strategy - Tenant-specific or isolated backups
- 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.