DigiSpace
  • автор admin

Архітектура Laravel Multi-Tenant SaaS: доменна оренда та ізоляція PostgreSQL

Архітектура Laravel Multi-Tenant SaaS: доменна оренда та ізоляція PostgreSQL

Мультиарендність легко описати й складно зробити безпечною. Один застосунок обслуговує кілька організацій, кожна очікує власних користувачів і даних, а власнику платформи все одно потрібен центральний простір для білінгу, підтримки та адміністрування. Визначення домену — лише перший крок. Справжня робота полягає в тому, щоб контекст орендаря залишався правильним у HTTP-запитах, автентифікації, чергах, подіях і фонових сервісах.

Бізнес-результат — контрольоване зростання: один продукт може обслуговувати багато клінік, їхні операційні дані залишаються розділеними, онбординг стає повторюваним, а команда змінює платформу без окремої кодової бази для кожного клієнта.

Починайте з межі, а не з колонки в таблиці

VetSpace — платформа для ветеринарних практик із центральним кабінетом власника та окремим застосунком клініки. Клініка отримує власний домен, персонал працює в її контексті, а власники тварин використовують спільний центральний інтерфейс. Це інша задача, ніж просто додати tenant_id до кількох таблиць. Застосунок має визначити контекст запиту до того, як прочитає дані за цим запитом.

Проєкт використовує Laravel 13, PostgreSQL і stancl/tenancy. Домени орендарів представлені окремою моделлю Domain, а Tenant використовує database- і domain-concerns пакета. Модель орендаря тому містить і бізнес-ідентичність, і параметри підключення, потрібні для ініціалізації правильного контексту.

Один застосунок, два API-контексти

VetSpace тримає центральні й tenant-маршрути в одному Laravel-застосунку, але вони виконують різну роботу. Центральний API володіє акаунтами, тваринами, замовленнями, каталогом клінік і підписками. Tenant-маршрути працюють із філіями, послугами, лікарями, кабінетами, записами, візитами та відгуками клінік.

Розділення видно у файлах маршрутів, а не заховане у великому контролері. Tenant-маршрути завантажує TenancyServiceProvider, а tenant_api.php ініціалізує домен до того, як запит потрапляє в контролер. Правило просте: центральний endpoint читає центральні дані, endpoint клініки працює в контексті бази цієї клініки.

Мультиарендна архітектура VetSpace: центральний і tenant API визначаються доменом

Обирайте ізоляцію відповідно до плану

Платформа не вважає всіх орендарів однаковими. Архітектура підтримує PostgreSQL-схему або окрему базу для орендаря, а план визначає потрібний рівень ізоляції. Спільна база з окремими схемами спрощує експлуатацію невеликих орендарів. Окрема база дає enterprise-орендарю сильнішу межу та простішу відповідь щодо backup, restore і розміщення даних.

У цього вибору є ціна. Сильніша ізоляція означає більше роботи з підключеннями, міграціями та моніторингом. Слабша зменшує операційні витрати, але робить application-level scoping і процедури відновлення важливішими. Правильним є рішення, що відповідає вимогам продукту, підтримки та відновлення; “database per tenant” не є автоматично найкращою відповіддю.

Порівняйте варіанти ізоляції до вибору

Поширені Laravel-підходи — спільні таблиці з ключем орендаря, окремі PostgreSQL-схеми та окремі бази. Спільні таблиці простіші в експлуатації, але кожна межа запиту має бути правильною. Схеми додають простір імен на рівні бази, зберігаючи одну PostgreSQL-інсталяцію. Окремі бази посилюють ізоляцію і відновлення конкретного орендаря, але збільшують роботу з підключеннями, міграціями та моніторингом.

У VetSpace вибір залежить від плану продукту. Платформа не платить за enterprise-ізоляцію для кожної маленької клініки, але має сильніший варіант, коли цього вимагає бізнес. Це продуктове рішення, виражене через інфраструктуру, а не налаштування пакета.

Порядок middleware є частиною моделі безпеки

У tenant API автентифікацію не можна розглядати як незалежний перший крок. Спочатку потрібно визначити орендаря, щоб застосунок знав, у якій базі шукати tenant-користувача або токен. Проєкт явно додає InitializeTenancyByDomain до пріоритету middleware у TenancyServiceProvider.

protected function makeTenancyMiddlewareHighestPriority(): void
{
    $tenancyMiddleware = [
        Middleware\PreventAccessFromCentralDomains::class,
        Middleware\InitializeTenancyByDomain::class,
        Middleware\InitializeTenancyBySubdomain::class,
    ];

    foreach (array_reverse($tenancyMiddleware) as $middleware) {
        $this->app[Kernel::class]
            ->prependToMiddlewarePriority($middleware);
    }
}

Оголошення маршруту пояснює, чому це важливо: навіть якщо auth:sanctum стоїть раніше в масиві, список пріоритету Laravel запускає ініціалізацію домену першою. Таку деталь варто закріпити тестом і коментарем, інакше майбутній розробник може “спростити” маршрут і змінити межу даних.

Створення орендаря — це життєвий цикл, а не insert

Створення орендаря запускає pipeline: створюється база, виконуються tenant-міграції, засіваються дані, створюється головна філія та копіюється власник. Видалення запускає відповідне очищення бази. Зібрані разом кроки роблять онбординг повторюваним і дають платформі одне місце для роботи з частковими помилками.

JobPipeline::make([
    Jobs\CreateDatabase::class,
    Jobs\MigrateDatabase::class,
    Jobs\SeedDatabase::class,
    CreateMainBranch::class,
    ReplicateOwnerToTenant::class,
])->send(fn (Events\TenantCreated $event) => $event->tenant)
  ->shouldBeQueued(false);

На цій межі pipeline навмисно синхронний. Створення клініки не повинно повідомляти про успіх, поки в неї немає таблиць або першої філії. Іншу роботу можна поставити в чергу після переходу орендаря у валідний стан.

Фонові jobs мають переносити tenant-контекст

Фонові jobs показують, чи працює мультиарендність насправді. RunAiAnalysis зберігає ID орендаря й аналізу, знаходить орендаря в центральному контексті, ініціалізує tenancy, виконує аналіз і завершує контекст у блоці finally. Це важливо для довгоживучого worker, який послідовно обробляє кількох орендарів.

try {
    tenancy()->initialize($tenant);
    $analysis = AiAnalysis::find($this->analysisId);
    $service->analyze($analysis);
} finally {
    if (tenancy()->initialized) {
        tenancy()->end();
    }
}

Такий самий принцип працює в jobs, які копіюють користувачів, створюють головну філію та синхронізують VetCard. Payload, що містить лише ID запису, недостатній, якщо цей ID має сенс тільки всередині конкретної tenant-бази.

Центральним записам теж потрібна друга межа

Не кожен запис належить лише одній клініці. Власники та тварини живуть у центральному просторі й можуть бути пов’язані з кількома клініками через pivot-таблиці. VetSpace використовує trait ScopesToClinic, щоб обмежувати читання й запис ID клінік користувача, за потреби включаючи філію. Це захищає від IDOR навіть тоді, коли сама модель центральна.

Цю різницю легко пропустити: ізоляція бази захищає tenant-локальні таблиці, а scoping зв’язків — спільні записи. Для платформи, де власник може відвідувати кілька клінік, потрібні обидві межі.

Що ця архітектура дає бізнесу

Цінність не в кількості Laravel-пакетів у composer.json. Вона в тому, що нову клініку можна додати без копіювання застосунку, кожен план отримує відповідний рівень ізоляції, а центральний білінг і tenant-операції залишаються пов’язаними без змішування даних. Ризики також стають видимими: tenant-міграції, контекст черги, маршрутизація доменів, backup і міжклінічний доступ стають явною частиною дизайну.

Коли цей підхід занадто дорогий

Невеликому внутрішньому інструменту для однієї організації не потрібні domain-based tenancy, менеджери tenant-баз і pipeline онбордингу. Простому membership-продукту може вистачити однієї бази з надійним row scoping. Мультиарендність виправдовує складність, коли кілька організацій ділять продукт, а ризик витоку даних, дубльованих деплоїв і ручного онбордингу дорожчий за інфраструктурні витрати.

Коли межа справді потрібна, проєктуйте її рано. Опишіть домени, вимоги до ізоляції даних та онбординг — і ми визначимо найменшу архітектуру, яка збереже ці межі надійними.

Контекст сервісу — на сторінці Розробка на Laravel.

Share this post