Multi-tenant architecture is fundamental to SaaS applications. The right database strategy impacts security, scalability, and operational complexity. At ZIRA Software, we help clients choose the optimal approach for their requirements.
Strategy Comparison
Database Strategies
├── Single Database (Shared Schema)
│ ├── tenant_id column on all tables
│ ├── Simplest implementation
│ ├── Lowest infrastructure cost
│ └── Risk: Cross-tenant data leaks
├── Separate Schemas (PostgreSQL)
│ ├── One schema per tenant
│ ├── Good isolation
│ ├── Single database server
│ └── Schema-level migrations
└── Database per Tenant
├── Complete isolation
├── Highest security
├── Per-tenant backups
└── Higher operational cost
Single Database Strategy
// Tenant model
class Tenant extends Model
{
protected $fillable = ['name', 'subdomain', 'settings'];
protected $casts = ['settings' => 'array'];
}
// Trait for tenant-scoped models
trait BelongsToTenant
{
protected static function bootBelongsToTenant()
{
static::creating(function ($model) {
if (!$model->tenant_id && app()->has('currentTenant')) {
$model->tenant_id = app('currentTenant')->id;
}
});
static::addGlobalScope('tenant', function ($query) {
if (app()->has('currentTenant')) {
$query->where('tenant_id', app('currentTenant')->id);
}
});
}
public function tenant()
{
return $this->belongsTo(Tenant::class);
}
}
// Usage in models
class Project extends Model
{
use BelongsToTenant;
protected $fillable = ['name', 'description', 'tenant_id'];
}
Tenant Resolution Middleware
// app/Http/Middleware/ResolveTenant.php
class ResolveTenant
{
public function handle($request, Closure $next)
{
$subdomain = $this->getSubdomain($request);
$tenant = Tenant::where('subdomain', $subdomain)->first();
if (!$tenant) {
abort(404, 'Tenant not found');
}
app()->instance('currentTenant', $tenant);
// Set tenant in session for queue jobs
session(['tenant_id' => $tenant->id]);
return $next($request);
}
protected function getSubdomain($request)
{
$host = $request->getHost();
$parts = explode('.', $host);
// Assuming format: tenant.example.com
if (count($parts) >= 3) {
return $parts[0];
}
return null;
}
}
Database-per-Tenant Strategy
// config/database.php
'connections' => [
'tenant' => [
'driver' => 'mysql',
'host' => env('DB_HOST'),
'port' => env('DB_PORT'),
'database' => '', // Set dynamically
'username' => env('DB_USERNAME'),
'password' => env('DB_PASSWORD'),
],
],
// Tenant database manager
class TenantDatabaseManager
{
public function connect(Tenant $tenant)
{
config(['database.connections.tenant.database' => $tenant->database]);
DB::purge('tenant');
DB::reconnect('tenant');
// Set as default connection
DB::setDefaultConnection('tenant');
}
public function createDatabase(Tenant $tenant)
{
$database = 'tenant_' . $tenant->id;
DB::statement("CREATE DATABASE `{$database}`");
$tenant->update(['database' => $database]);
$this->connect($tenant);
// Run tenant migrations
Artisan::call('migrate', [
'--database' => 'tenant',
'--path' => 'database/migrations/tenant',
]);
// Seed default data
Artisan::call('db:seed', [
'--class' => 'TenantSeeder',
]);
}
public function deleteDatabase(Tenant $tenant)
{
DB::statement("DROP DATABASE IF EXISTS `{$tenant->database}`");
}
}
Queue Jobs with Tenancy
// Base job class
abstract class TenantAwareJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $tenantId;
public function __construct()
{
$this->tenantId = app('currentTenant')->id ?? session('tenant_id');
}
public function handle()
{
$tenant = Tenant::find($this->tenantId);
if ($tenant) {
app()->instance('currentTenant', $tenant);
app(TenantDatabaseManager::class)->connect($tenant);
}
$this->process();
}
abstract protected function process();
}
// Usage
class GenerateReport extends TenantAwareJob
{
protected function process()
{
// All queries automatically scoped to tenant
$projects = Project::all();
// Generate report...
}
}
PostgreSQL Schema Strategy
// Schema-based multi-tenancy
class SchemaManager
{
public function createSchema(Tenant $tenant)
{
$schema = 'tenant_' . $tenant->id;
DB::statement("CREATE SCHEMA {$schema}");
$tenant->update(['schema' => $schema]);
$this->switchSchema($tenant);
Artisan::call('migrate', ['--path' => 'database/migrations/tenant']);
}
public function switchSchema(Tenant $tenant)
{
DB::statement("SET search_path TO {$tenant->schema}, public");
}
}
Conclusion
Choose single database for simplicity and low cost, database-per-tenant for maximum isolation, or PostgreSQL schemas for a middle ground. Each approach has trade-offs—match them to your security and operational requirements.
Building multi-tenant SaaS? Contact ZIRA Software for architecture consulting.