Scaling Multi-Tenant SaaS Applications
Building a multi-tenant SaaS application that scales to thousands of customers requires careful architectural decisions from day one. After scaling our SaaS platform from 10 to 1000+ tenants, I learned that the choices you make early have massive implications later. Here’s what worked (and what didn’t).
Multi-Tenancy Models
There are three main approaches to multi-tenancy, each with distinct tradeoffs:
1. Shared Database, Shared Schema
All tenants share the same database and tables, distinguished by a tenant_id column.
CREATE TABLE users (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
email VARCHAR(255) NOT NULL,
name VARCHAR(255),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(tenant_id, email)
);
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
tenant_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
total DECIMAL(10,2),
created_at TIMESTAMP DEFAULT NOW(),
FOREIGN KEY (tenant_id, user_id) REFERENCES users(tenant_id, id)
);
CREATE INDEX idx_orders_tenant ON orders(tenant_id);
Pros:
- Simplest to implement
- Most cost-effective
- Easy schema updates
Cons:
- Risk of data leakage
- Difficult to customize per tenant
- Single point of failure
2. Shared Database, Separate Schema
Each tenant gets their own schema within a shared database.
-- Tenant 1
CREATE SCHEMA tenant_1;
CREATE TABLE tenant_1.users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE,
name VARCHAR(255)
);
-- Tenant 2
CREATE SCHEMA tenant_2;
CREATE TABLE tenant_2.users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE,
name VARCHAR(255)
);
Application layer routing:
class TenantConnection
{
public function setTenant($tenantId)
{
$schema = "tenant_{$tenantId}";
DB::statement("SET search_path TO {$schema}");
}
public function getTenantConnection($tenantId)
{
$this->setTenant($tenantId);
return DB::connection();
}
}
Pros:
- Better data isolation
- Can customize schema per tenant
- Easier backup/restore per tenant
Cons:
- More complex queries
- Schema migrations harder
- Limited by database connection limits
3. Separate Database Per Tenant
Each tenant gets a dedicated database.
class MultiTenantDatabase
{
private $connections = [];
public function getConnection($tenantId)
{
if (!isset($this->connections[$tenantId])) {
$config = [
'driver' => 'mysql',
'host' => $this->getHostForTenant($tenantId),
'database' => "tenant_{$tenantId}",
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
];
$this->connections[$tenantId] = new PDO(
"mysql:host={$config['host']};dbname={$config['database']}",
$config['username'],
$config['password']
);
}
return $this->connections[$tenantId];
}
private function getHostForTenant($tenantId)
{
// Distribute tenants across database servers
$servers = [
'db1.example.com',
'db2.example.com',
'db3.example.com',
];
return $servers[$tenantId % count($servers)];
}
}
Pros:
- Complete isolation
- Easy to scale specific tenants
- Can use different hardware per tenant
- Easier compliance (data residency)
Cons:
- Most expensive
- Complex schema migrations
- More operational overhead
Our Hybrid Approach
We use a hybrid model based on tenant tier:
class TenantRouter
{
public function getConnection($tenant)
{
switch ($tenant->tier) {
case 'enterprise':
// Dedicated database
return $this->getEnterpriseConnection($tenant);
case 'professional':
// Separate schema
return $this->getSchemaConnection($tenant);
case 'starter':
// Shared schema
return $this->getSharedConnection($tenant);
}
}
}
Tenant Identification
Subdomain-Based
# Nginx configuration
server {
listen 80;
server_name *.myapp.com;
location / {
proxy_pass http://app_servers;
proxy_set_header X-Tenant-Subdomain $host;
}
}
class TenantMiddleware
{
public function handle($request, Closure $next)
{
$subdomain = $this->extractSubdomain($request->getHost());
$tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();
// Set tenant context
app()->instance('tenant', $tenant);
// Set database connection
DB::setDefaultConnection("tenant_{$tenant->id}");
return $next($request);
}
private function extractSubdomain($host)
{
$parts = explode('.', $host);
return $parts[0];
}
}
Header-Based (for APIs)
class ApiTenantMiddleware
{
public function handle($request, Closure $next)
{
$apiKey = $request->header('X-API-Key');
if (!$apiKey) {
return response()->json(['error' => 'Missing API key'], 401);
}
$tenant = Tenant::where('api_key', $apiKey)->firstOrFail();
app()->instance('tenant', $tenant);
return $next($request);
}
}
Query Scoping
Automatically scope all queries to current tenant:
// Model trait
trait BelongsToTenant
{
protected static function bootBelongsToTenant()
{
// Automatically scope queries
static::addGlobalScope('tenant', function ($builder) {
if (app()->has('tenant')) {
$builder->where('tenant_id', app('tenant')->id);
}
});
// Automatically set tenant on create
static::creating(function ($model) {
if (app()->has('tenant') && !$model->tenant_id) {
$model->tenant_id = app('tenant')->id;
}
});
}
}
// Usage
class User extends Model
{
use BelongsToTenant;
}
// Queries automatically scoped
$users = User::all(); // Only returns current tenant's users
$user = User::find(1); // Only finds if belongs to current tenant
Data Isolation and Security
Row-Level Security in PostgreSQL
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Create policy
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant')::integer);
-- Set tenant in session
SET app.current_tenant = '123';
Application layer:
class SecureQuery
{
public function query($tenantId, $sql, $params = [])
{
// Set tenant context
DB::statement("SET app.current_tenant = ?", [$tenantId]);
// Execute query - RLS automatically enforced
return DB::select($sql, $params);
}
}
Preventing Cross-Tenant Queries
class TenantValidator
{
public function validateQuery($sql, $tenantId)
{
// Parse SQL
$tables = $this->extractTables($sql);
// Verify all tables have tenant_id filter
foreach ($tables as $table) {
if (!$this->hasTenantFilter($sql, $table)) {
throw new SecurityException(
"Query missing tenant filter for {$table}"
);
}
}
}
public function sanitizeInput($data, $tenantId)
{
// Ensure tenant_id matches current tenant
if (isset($data['tenant_id']) && $data['tenant_id'] != $tenantId) {
throw new SecurityException('Tenant ID mismatch');
}
// Add tenant_id if missing
$data['tenant_id'] = $tenantId;
return $data;
}
}
Performance Optimization
Tenant-Aware Caching
class TenantCache
{
private $redis;
private $ttl = 3600;
public function get($key)
{
$tenant = app('tenant');
$cacheKey = "tenant:{$tenant->id}:{$key}";
return $this->redis->get($cacheKey);
}
public function set($key, $value, $ttl = null)
{
$tenant = app('tenant');
$cacheKey = "tenant:{$tenant->id}:{$key}";
return $this->redis->setex(
$cacheKey,
$ttl ?? $this->ttl,
serialize($value)
);
}
public function flush()
{
$tenant = app('tenant');
$pattern = "tenant:{$tenant->id}:*";
$keys = $this->redis->keys($pattern);
if (!empty($keys)) {
$this->redis->del($keys);
}
}
}
Database Connection Pooling
class ConnectionPool
{
private $pools = [];
private $maxConnections = 10;
public function getConnection($tenantId)
{
if (!isset($this->pools[$tenantId])) {
$this->pools[$tenantId] = [];
}
// Find available connection
foreach ($this->pools[$tenantId] as $conn) {
if (!$conn->inUse) {
$conn->inUse = true;
return $conn;
}
}
// Create new if under limit
if (count($this->pools[$tenantId]) < $this->maxConnections) {
$conn = $this->createConnection($tenantId);
$conn->inUse = true;
$this->pools[$tenantId][] = $conn;
return $conn;
}
// Wait for available connection
return $this->waitForConnection($tenantId);
}
public function releaseConnection($conn)
{
$conn->inUse = false;
}
}
Resource Limits Per Tenant
class TenantLimits
{
public function checkLimit($tenant, $resource, $count = 1)
{
$limits = [
'starter' => [
'users' => 10,
'storage_mb' => 100,
'api_calls_per_day' => 1000,
],
'professional' => [
'users' => 100,
'storage_mb' => 1000,
'api_calls_per_day' => 10000,
],
'enterprise' => [
'users' => PHP_INT_MAX,
'storage_mb' => PHP_INT_MAX,
'api_calls_per_day' => PHP_INT_MAX,
],
];
$tierLimits = $limits[$tenant->tier];
$current = $this->getCurrentUsage($tenant, $resource);
if ($current + $count > $tierLimits[$resource]) {
throw new LimitExceededException(
"Limit exceeded for {$resource}. Upgrade your plan."
);
}
return true;
}
private function getCurrentUsage($tenant, $resource)
{
switch ($resource) {
case 'users':
return User::where('tenant_id', $tenant->id)->count();
case 'storage_mb':
return $this->calculateStorageUsage($tenant);
case 'api_calls_per_day':
return $this->getApiCallCount($tenant);
}
}
}
Schema Migrations
class TenantMigrator
{
public function migrate($migration)
{
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
try {
echo "Migrating tenant {$tenant->id}...";
// Set tenant context
$this->setTenantConnection($tenant);
// Run migration
Artisan::call('migrate', [
'--path' => $migration,
'--database' => "tenant_{$tenant->id}",
]);
echo " ✓\n";
} catch (Exception $e) {
echo " ✗ Error: {$e->getMessage()}\n";
// Log error but continue
Log::error("Migration failed for tenant {$tenant->id}", [
'error' => $e->getMessage(),
'migration' => $migration,
]);
}
}
}
public function rollback($steps = 1)
{
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
$this->setTenantConnection($tenant);
Artisan::call('migrate:rollback', [
'--step' => $steps,
'--database' => "tenant_{$tenant->id}",
]);
}
}
}
Monitoring and Analytics
class TenantMetrics
{
public function recordMetric($tenant, $metric, $value)
{
$key = "metrics:tenant:{$tenant->id}:{$metric}:" . date('Y-m-d-H');
Redis::hincrby($key, 'count', 1);
Redis::hincrbyfloat($key, 'sum', $value);
Redis::expire($key, 86400 * 7); // 7 days retention
}
public function getMetrics($tenant, $metric, $hours = 24)
{
$data = [];
for ($i = 0; $i < $hours; $i++) {
$hour = date('Y-m-d-H', strtotime("-{$i} hours"));
$key = "metrics:tenant:{$tenant->id}:{$metric}:{$hour}";
$count = Redis::hget($key, 'count') ?? 0;
$sum = Redis::hget($key, 'sum') ?? 0;
$data[] = [
'hour' => $hour,
'count' => $count,
'average' => $count > 0 ? $sum / $count : 0,
];
}
return $data;
}
}
Handling Tenant Churn
class TenantOffboarding
{
public function suspend($tenant)
{
$tenant->update(['status' => 'suspended']);
// Clear cache
Cache::tags("tenant:{$tenant->id}")->flush();
// Notify admins
Notification::send(
$tenant->admins,
new AccountSuspendedNotification()
);
}
public function deleteData($tenant)
{
DB::transaction(function() use ($tenant) {
// Soft delete tenant data
User::where('tenant_id', $tenant->id)->delete();
Order::where('tenant_id', $tenant->id)->delete();
// Schedule hard delete after grace period
DeleteTenantDataJob::dispatch($tenant)
->delay(now()->addDays(30));
});
}
public function exportData($tenant)
{
$data = [
'users' => User::where('tenant_id', $tenant->id)->get(),
'orders' => Order::where('tenant_id', $tenant->id)->get(),
];
$json = json_encode($data, JSON_PRETTY_PRINT);
Storage::put(
"exports/tenant-{$tenant->id}-" . date('Y-m-d') . ".json",
$json
);
return Storage::url("exports/tenant-{$tenant->id}-" . date('Y-m-d') . ".json");
}
}
Conclusion
Scaling multi-tenant SaaS applications requires:
- Choose the right tenancy model for your use case
- Implement automatic tenant scoping
- Enforce strict data isolation
- Monitor per-tenant resource usage
- Plan schema migrations carefully
- Implement graceful tenant offboarding
Our hybrid approach (shared/schema/dedicated based on tier) gave us the best balance of cost and isolation. Start simple with shared schema, then migrate high-value customers to dedicated resources as needed.
The most important lesson: Always scope queries by tenant_id. One missing WHERE clause can leak data across tenants.
Lessons learned from scaling a multi-tenant SaaS platform in 2016.