Multi-tenancy for SaaS web application
Multi-tenancy is an architecture where one installation serves multiple independent organizations (tenants). Each tenant sees only their data. Three architectural models differ in isolation level, cost, and complexity.
Three multi-tenancy models
Pool (Shared Everything): all tenants in one database, each table has tenant_id column. Cheap, scales easily, but isolation only at application level.
Silo (Database per Tenant): each tenant — separate database. Full isolation, easy tenant data migration, compliance (GDPR right to erasure — just delete DB). Expensive with many tenants.
Bridge (Schema per Tenant): one PostgreSQL cluster, separate schemas (tenant_acme, tenant_globex). Compromise: good isolation, one PostgreSQL process, complex schema management.
Pool model: Row-Level Security
Most common approach for SaaS. Protection at PostgreSQL level, not just ORM:
-- Table with tenant_id
ALTER TABLE articles ADD COLUMN tenant_id uuid NOT NULL REFERENCES tenants(id);
CREATE INDEX articles_tenant_id_idx ON articles(tenant_id);
-- RLS policy
ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON articles
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- Set context before queries
SET app.tenant_id = '550e8400-e29b-41d4-a716-446655440000';
SELECT * FROM articles; -- automatically sees only own
Laravel Tenancy integration:
// TenantScope — global scope for all models
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where($model->getTable() . '.tenant_id', tenant()->id);
}
}
// HasTenant trait
trait HasTenant
{
protected static function bootHasTenant(): void
{
static::addGlobalScope(new TenantScope());
static::creating(function ($model) {
$model->tenant_id ??= tenant()->id;
});
}
}
// Initialize tenant from request
class InitializeTenancy
{
public function handle(Request $request, Closure $next)
{
$subdomain = explode('.', $request->getHost())[0];
$tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();
app()->instance('tenant', $tenant);
// Set PostgreSQL context for RLS
DB::statement("SET app.tenant_id = '{$tenant->id}'");
return $next($request);
}
}
Tenant identification
Subdomain: acme.app.example.com — most convenient.
// Route by subdomain
Route::domain('{tenant}.example.com')->group(function () {
Route::middleware([InitializeTenancy::class])->group(function () {
// All protected routes
});
});
Custom domain: app.acme.com → wildcard SSL (Let's Encrypt, Caddy automatic HTTPS) + DNS record + database record.
Path-based: example.com/acme/... — no SSL issues, but less professional URL.
Silo model: Dynamic Database Connections
// Dynamic connection switching
class TenantDatabaseManager
{
public function connectTenant(Tenant $tenant): void
{
$config = [
'driver' => 'pgsql',
'host' => $tenant->db_host ?? config('database.connections.pgsql.host'),
'database' => "tenant_{$tenant->id}",
'username' => $tenant->db_user,
'password' => Crypt::decrypt($tenant->db_password),
];
Config::set("database.connections.tenant", $config);
DB::purge('tenant');
DB::reconnect('tenant');
DB::setDefaultConnection('tenant');
}
}
Migrations for all tenants:
// Artisan command: tenants:migrate
Tenant::each(function (Tenant $tenant) {
app(TenantDatabaseManager::class)->connectTenant($tenant);
Artisan::call('migrate', ['--force' => true]);
});
Schema model on PostgreSQL
-- Create schema for new tenant
CREATE SCHEMA tenant_acme;
-- Copy structure from template schema
SELECT clone_schema('tenant_template', 'tenant_acme');
-- Connect to schema
SET search_path TO tenant_acme, public;
// search_path as tenant context
DB::statement("SET search_path TO tenant_{$tenant->slug}, public");
New tenant onboarding
class ProvisionTenantJob implements ShouldQueue
{
public function handle(Tenant $tenant): void
{
// 1. Create database/schema
TenantDatabaseManager::create($tenant);
// 2. Run migrations
Artisan::call('tenants:migrate', ['--tenant' => $tenant->id]);
// 3. Seeds: default roles, settings
Artisan::call('tenants:seed', ['--tenant' => $tenant->id]);
// 4. DNS if needed (Cloudflare API)
CloudflareDNS::createSubdomain($tenant->subdomain);
// 5. Welcome email
Mail::to($tenant->owner_email)->send(new TenantWelcome($tenant));
$tenant->update(['status' => 'active']);
}
}
Cross-tenant data
Some data is global — not bound to tenant:
// Models without TenantScope
class Country extends Model { } // no HasTenant
class Plan extends Model { } // pricing plans — global
// Super admin sees all tenants
class SuperAdminScope
{
public function apply(Builder $builder, Model $model): void
{
if (!auth()->user()?->isSuperAdmin()) {
$builder->where('tenant_id', tenant()->id);
}
}
}
File isolation
// S3/MinIO — separate prefix per tenant
Storage::disk('s3')->put(
"tenants/{$tenant->id}/uploads/{$filename}",
$fileContent
);
// Or separate buckets for enterprise tier
$bucket = $tenant->plan === 'enterprise'
? "tenant-{$tenant->id}"
: "tenants-shared";
Feature flags per tenant
// Enable features at tenant level
class TenantFeature extends Model
{
// tenant_id, feature, enabled, config (JSON)
}
// Usage
if (tenant()->hasFeature('advanced_analytics')) {
// Show BI dashboard
}
// Or via Laravel Pennant
Feature::for($tenant)->active('advanced_analytics');
Timeline
Pool model with RLS, TenantScope, subdomain routing, provisioning job: 2–3 weeks. Silo with dynamic connections, custom domains, wildcard SSL, cross-tenant analytics for superadmin: 1–2 months.







