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