Multi-tenancy для SaaS веб-приложення
Multi-tenancy — архітектура, при якій одна інсталяція приложення обслуговує декілька незалежних організацій (tenant'ів). Кожен tenant бачить тільки свої дані. Три принципові моделі відрізняються рівнем ізоляції, вартістю та складністю.
Три моделі multi-tenancy
Pool (Shared Everything): усі tenant'и в одній базі, у кожній таблиці колонка tenant_id. Дешево, легко масштабувати, але ізоляція тільки на рівні приложення.
Silo (Database per Tenant): кожен tenant — окремна база. Повна ізоляція, проста міграція даних tenant'а, compliance (GDPR right to erasure — просто видалити базу). Дорого при великій кількості tenant'ів.
Bridge (Schema per Tenant): один PostgreSQL кластер, окремі схеми (tenant_acme, tenant_globex). Компроміс: хорошая ізоляція, один процес PostgreSQL, але управління схемами складніше.
Pool-модель: Row-Level Security
Найпоширеніший підхід для SaaS. Захист на рівні PostgreSQL, а не тільки на рівні ORM:
-- Таблиця з 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 політика
ALTER TABLE articles ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON articles
USING (tenant_id = current_setting('app.tenant_id')::uuid);
-- Встановлення контексту перед запитами
SET app.tenant_id = '550e8400-e29b-41d4-a716-446655440000';
SELECT * FROM articles; -- автоматично бачить тільки свої
Laravel Tenancy інтеграція:
// TenantScope — глобальний scope для всіх моделей
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;
});
}
}
// Ініціалізація tenant з запиту
class InitializeTenancy
{
public function handle(Request $request, Closure $next)
{
$subdomain = explode('.', $request->getHost())[0];
$tenant = Tenant::where('subdomain', $subdomain)->firstOrFail();
app()->instance('tenant', $tenant);
// Встановлюємо PostgreSQL context для RLS
DB::statement("SET app.tenant_id = '{$tenant->id}'");
return $next($request);
}
}
Ідентифікація tenant'а
Поддомен: acme.app.example.com — самий зручний способ.
// Роутинг по поддомену
Route::domain('{tenant}.example.com')->group(function () {
Route::middleware([InitializeTenancy::class])->group(function () {
// Усі захищені роути
});
});
Кастомний домен: app.acme.com → wildcard SSL (Let's Encrypt, Caddy automatic HTTPS) + запис в DNS + запис в базі.
Path-based: example.com/acme/... — немає SSL-проблем, але URL виглядає менш професійно.
Silo-модель: Dynamic Database Connections
// Динамічне переключення з'єднання
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');
}
}
Міграції для всіх tenant'ів:
// Artisan command: tenants:migrate
Tenant::each(function (Tenant $tenant) {
app(TenantDatabaseManager::class)->connectTenant($tenant);
Artisan::call('migrate', ['--force' => true]);
});
Schema-модель на PostgreSQL
-- Створення схеми для нового tenant'а
CREATE SCHEMA tenant_acme;
-- Копіювання структури зі шаблонної схеми
SELECT clone_schema('tenant_template', 'tenant_acme');
-- Підключення до схеми
SET search_path TO tenant_acme, public;
// search_path як tenant-контекст
DB::statement("SET search_path TO tenant_{$tenant->slug}, public");
Onboarding нового tenant'а
class ProvisionTenantJob implements ShouldQueue
{
public function handle(Tenant $tenant): void
{
// 1. Створити базу/схему
TenantDatabaseManager::create($tenant);
// 2. Запустити міграції
Artisan::call('tenants:migrate', ['--tenant' => $tenant->id]);
// 3. Сиди: ролі по умовчанню, налаштування
Artisan::call('tenants:seed', ['--tenant' => $tenant->id]);
// 4. DNS якщо потрібно (Cloudflare API)
CloudflareDNS::createSubdomain($tenant->subdomain);
// 5. Email приветствия
Mail::to($tenant->owner_email)->send(new TenantWelcome($tenant));
$tenant->update(['status' => 'active']);
}
}
Cross-tenant дані
Деякі дані глобальні — не привязані до tenant:
// Моделі без TenantScope
class Country extends Model { } // немає HasTenant
class Plan extends Model { } // тарифні плани — глобальні
// Суперадмін бачить усіх tenant'ів
class SuperAdminScope
{
public function apply(Builder $builder, Model $model): void
{
if (!auth()->user()?->isSuperAdmin()) {
$builder->where('tenant_id', tenant()->id);
}
}
}
Ізоляція файлів
// S3/MinIO — окремий prefix для кожного tenant'а
Storage::disk('s3')->put(
"tenants/{$tenant->id}/uploads/{$filename}",
$fileContent
);
// Або окремі бакети для enterprise-tier
$bucket = $tenant->plan === 'enterprise'
? "tenant-{$tenant->id}"
: "tenants-shared";
Feature flags per tenant
// Включення фіч на рівні tenant'а
class TenantFeature extends Model
{
// tenant_id, feature, enabled, config (JSON)
}
// Використання
if (tenant()->hasFeature('advanced_analytics')) {
// Показати BI дашборд
}
// Або через Laravel Pennant
Feature::for($tenant)->active('advanced_analytics');
Сроки
Pool-модель з RLS, TenantScope, subdomain routing, provisioning job: 2–3 тижні. Silo з dynamic connections, custom domains, wildcard SSL, cross-tenant аналітика для superadmin: 1–2 місяці.







