Архітектура 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 клініки працює в контексті бази цієї клініки.
Обирайте ізоляцію відповідно до плану
Платформа не вважає всіх орендарів однаковими. Архітектура підтримує 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.
Модернізація PHP-застосунку: безпечний шлях від legacy до PHP 8.3
“Модернізуйте PHP-застосунок” звучить як технологічне завдання. У живій бізнес-системі це завдання з управління ризиками: користувачам потрібні старі процеси, інтеграції очікують старі payload, а бізнес не можна зупинити, поки кожен клас переписується. Найбезпечніший шлях — послідовність спостережуваних змін, які зменшують невідоме до зміни архітектури.
Бізнес-результат — кодова база, яку команда змінює з меншим страхом: поведінка стає тестованою, залежності — керованими, а кожен крок модернізації можна випустити або відкотити без повного rewrite.
Починайте з доказів, а не з рішення про фреймворк
До вибору Laravel, Symfony або чистого PHP опишіть, що насправді робить застосунок. Зафіксуйте версії PHP та розширень, обмеження Composer, entry points, scheduled commands, queue consumers, бази даних, зовнішні API й умови деплою. Пошукайте динамічні include, глобальний стан, прямий SQL, записи у файлову систему та error handlers, що змінюють потік виконання. Ці деталі точніше визначають поверхню міграції, ніж назва фреймворку.
PHP-проєкт DigiSpace працює на PHP 8.3 і Laravel 13 з MySQL 8. Публічний сайт використовує Blade, адмін-панель — Inertia та Vue, а застосунок інтегрується із Sanctum, Sentry, MinIO, Zoho CRM і reCAPTCHA. Кожна інтеграція — межа, яку треба перевірити під час оновлення: застосунок може успішно скомпілюватися й водночас втратити webhook, завантаження або межу автентифікації.
Зробіть поточну поведінку видимою
У legacy-системах часто немає тестів для поведінки, яка справді важлива. Додайте characterization tests до зміни реалізації. Request-тест може зафіксувати статуси, редиректи й помилки валідації. Integration-тест — форму CRM payload або завантаженого файла. Невеликий database fixture може захистити важливі правила сортування та фільтрації.
Ці тести не стверджують, що поточна поведінка ідеальна. Вони показують, на що сьогодні спирається бізнес. Коли межа явна, можна змінювати внутрішню реалізацію і свідомо вирішувати, коли поведінка має змінитися.
public function test_service_search_preserves_the_public_contract(): void
{
Service::factory()->create([
'title' => 'Laravel Development',
'status' => 'active',
]);
$response = $this->get('/en/services/search?search=Laravel');
$response->assertOk()
->assertSee('Laravel Development');
}
Приклад навмисно малий. Хороший characterization test захищає видимий контракт і залишається зрозумілим після зміни реалізації.
Оновлюйте runtime до повного redesign
Оновлення runtime виявляє припущення, які ховалися старими версіями. Переводьте код на підтримувані версії у контрольованій гілці, запускайте тести й статичний аналіз на кожному кроці, а deprecation notices виправляйте, а не вимикайте. Політика підтримки PHP дає для цього практичну причину: активна та security-підтримка обмежені в часі.
PHP 8.3 дає інструменти для явних меж, зокрема типізовані class constants, readonly-класи та сильніші type declarations. Використовуйте їх там, де вони описують наявний інваріант. Додавати їх усюди одразу означає створити зайвий churn; на межах сервісів, DTO та конфігурації вони спрощують пошук помилок.
Ставте типізовані межі навколо нетипізованого коду
Модернізація не вимагає негайно перетворити кожну legacy-функцію на ідеальну domain model. Створіть невеликий adapter зі типізованим входом і виходом, а новий код нехай залежить від цього контракту. Adapter нормалізує null, legacy-масиви та помилки провайдера.
final readonly class PublishResult
{
public function __construct(
public string $externalId,
public bool $published,
) {}
}
interface ChannelPublisher
{
public function publish(Post $post): PublishResult;
}
У DigiSpace такий стиль відповідає розділенню на controllers, form requests, models і services. Клас на кшталт ServiceSaveRequest перевіряє межу, після чого сервіс працює з перевіреними значеннями. Покращення не в кількості класів, а в тому, що місце відхилення невалідного input відоме.
Замінюйте slices, поки старий шлях ще працює
Для великої кодової бази strangler-підхід зазвичай безпечніший за rewrite. Оберіть бізнес-сегмент із чітким input і output: search endpoint, import, billing adapter або admin workflow. Підключіть нову реалізацію за тим самим контрактом, порівняйте поведінку й видаляйте старий шлях лише після роботи в реальних умовах.
Це захищає й деплої. Feature flag, перемикач маршруту або оборотна конфігурація дає змогу повернути стару реалізацію під час розслідування проблеми. Перемикач має мати власника й дату видалення; постійні два шляхи — це інша форма legacy.
Відокремлюйте міграцію даних від міграції коду
Зміна таблиць і поведінки в одному релізі ускладнює діагностику. Спочатку віддавайте перевагу additive schema changes: додайте nullable column або нову таблицю, випустіть код, який читає обидві форми, виконайте backfill контрольованою job, а потім зробіть нову форму основною. Стару колонку і compatibility code видаляйте пізніше.
Це саме стосується інтеграцій. Пишіть новий payload поруч зі старим, порівнюйте результат у логах або sandbox і перемикайте виклик провайдера після перевірки контракту. Sentry та структуровані application logs корисніші, якщо кожен крок записує, який шлях обробив запит.
Деплой є частиною дизайну
Модернізованому застосунку потрібен нудний release path. DigiSpace локально працює в Docker через Laravel Sail і деплоїться release-symlink workflow. Реліз — це не просто копіювання файлів: залежності, міграції, public assets, прогрів кешу й поточний symlink мають узгоджуватися. Rollback реальний лише тоді, коли попередній реліз і сумісний стан бази доступні.
Перед cutover перевіряйте важливі потоки на release candidate: login, публічні сторінки послуг, форми, uploads, queue work, CRM delivery і sitemap. Це дешевше, ніж після релізу з’ясовувати, що оновлення runtime змінило middleware order або шлях до asset.
Що модернізація дає бізнесу
Цінність накопичується. Підтримуваний runtime зменшує security та hosting friction. Тести здешевлюють зміни процесів. Типізовані межі прояснюють відповідальність. Менші релізи простіше відкотити. Для цього не треба стверджувати, що нова архітектура ідеальна: достатньо перетворити приховані припущення на явні межі.
Коли rewrite є чесною відповіддю
Інкрементальна модернізація не завжди дешевша. Якщо модель даних неправильна, runtime не підтримується, деплой недоступний і жодну бізнес-поведінку не можна ізолювати, replacement може бути виправданим. Навіть тоді мігруйте capability за capability, а стару систему використовуйте як джерело поведінки. Rewrite — це стратегія доставки, а не дозвіл викинути те, від чого залежать користувачі.
Контекст сервісу — на сторінці Розробка на PHP. Якщо ви обираєте між upgrade, інкрементальним replacement і новим Laravel/Symfony-застосунком, почніть із runtime, інтеграцій і бізнес-процесів, які не можуть зламатися.
Архітектура Symfony для складних PHP-застосунків: модулі, Messenger та інтеграції
Symfony корисний тоді, коли PHP-застосунок переростає набір контролерів та інтеграцій. Фреймворк дає команді явні межі — HTTP, валідацію, серіалізацію, обмін повідомленнями й конфігурацію — не нав’язуючи одну форму для всього продукту. Цінність з’являється тоді, коли ці межі залишаються зрозумілими після кількох років роботи системи.
Бізнес-результат — система, яка поглинає складність без перетворення кожної зміни на ризикований реліз: доменні модулі залишаються читабельними, повільна робота виходить із request path, а зовнішні інтеграції можна замінити за відомим контрактом.
Обирайте Symfony за формою задачі
Невеликому CRUD-застосунку не потрібна складна архітектура. Symfony має сенс, коли в системі кілька бізнес-доменів, довгоживучі інтеграції, асинхронні workflow або команда хоче використовувати окремі компоненти поза full-stack монолітом. Рішення має випливати з цих обмежень, а не з прихильності до бренду фреймворку.
Публічний застосунок DigiSpace побудований на Laravel, тому ця стаття є технічним посібником, а не заявою про production-репозиторій на Symfony. Патерни спираються на офіційні компоненти Symfony та ті самі інженерні питання, які видно в нашій PHP-роботі: явна валідація запитів, тонкі контролери, тестовані сервіси, черги, зовнішні API й деплої з можливістю відкату.
Залишайте HTTP-шар простим
Symfony-контролер має перетворити HTTP-запит на application command, а результат — на HTTP-відповідь. Він не повинен вирішувати, як рахується рахунок, як повторюється виклик провайдера або як координується транзакція. Такі рішення належать application services або handlers, які можна тестувати без запуску всього kernel.
final class CreateOrderController
{
public function __construct(
private CreateOrderHandler $handler,
) {}
public function __invoke(CreateOrderRequest $request): JsonResponse
{
$order = $this->handler->handle(
new CreateOrderCommand(
customerId: $request->customerId(),
lines: $request->lines(),
),
);
return new JsonResponse(['id' => $order->id()], Response::HTTP_CREATED);
}
}
Важлива саме межа. Валідація може відхилити неправильний input на краю, а handler працює з command відомої форми. Це спрощує майбутній перехід від синхронного контролера до message handler.
Організовуйте код навколо доменів, а не папок
Symfony не вимагає одного layout директорій. У системі, що зростає, організовуйте код навколо бізнес-можливостей: Orders, Billing або Catalog. Кожен модуль може містити application commands, domain rules, infrastructure adapters і HTTP entry points. Важливіше за назви папок те, щоб залежності мали зрозумілий напрям.
Доменний модуль не повинен знати, чи email відправив Symfony Mailer, queue worker або test double. Він має виразити event або command. Інфраструктура вирішує, як це повідомлення передається. Так зміна провайдера стає обмеженою задачею, а не пошуком по всіх контролерах.
Використовуйте Messenger, коли запит має завершуватися швидше
Symfony Messenger підтримує і негайну обробку, і transport, що виконує повідомлення пізніше. Команда може почати із синхронної шини повідомлень, а окрему роботу перенести в queue, коли цього вимагають latency або failure behaviour. Message — це мала серіалізована інструкція, а handler володіє side effect.
final readonly class GenerateInvoice
{
public function __construct(public string $invoiceId) {}
}
#[AsMessageHandler]
final class GenerateInvoiceHandler
{
public function __invoke(GenerateInvoice $message): void
{
// Завантажити рахунок, створити PDF, зберегти його, повідомити користувача.
}
}
Фонова робота потребує operational policy. Налаштуйте retry для тимчасових помилок провайдера, failure transport для повідомлень, які не обробилися, та idempotency rule, щоб повтор не створив подвійне списання або повідомлення. Queue — це механізм доставки, а не автоматична безпека неідемпотентної операції.
Ховайте зовнішні системи за adapter-ами
Symfony HttpClient дає єдиний client abstraction, але він не повинен просочувати формати відповіді провайдера в application code. Створіть невеликий adapter, який мапить provider request і response у власні value objects. Timeout, authentication, retry policy та logging тримайте в adapter або його transport configuration.
Це особливо корисно для платежів, синхронізації CRM та webhook. Застосунок може тестувати помилку провайдера без реального виклику, а adapter — мати окремий contract test проти sandbox. Коли провайдер змінює payload, спочатку змінюється одна межа.
Робіть конфігурацію та секрети явними
Довгоживучі Symfony-системи часто ламаються на конфігурації: worker має інше середовище, CLI-команда не бачить секрет або staging endpoint потрапляє в production. Розглядайте конфігурацію як input застосунку. Перевіряйте обов’язкові значення під час старту, зберігайте секрети поза репозиторієм, а середовище worker внесіть до deployment checklist.
Не ховайте операційні рішення у static globals. Типізований configuration object або injected parameter робить залежність видимою в constructor і зрозумілою в тесті.
Тестуйте контракти, які справді важливі
Unit-тести корисні для domain rules, але не замінюють HTTP та integration tests. Практичний Symfony-набір має три рівні: швидкі тести розрахунків і політик, application tests для command та handler behaviour і менший набір HTTP-тестів для routing, validation та serialization. Інтеграційні тести мають покривати межі, де губляться дані: queues, webhook, databases та external clients.
Під час модернізації наявного PHP-застосунку characterization tests — безпечний перший крок. Зафіксуйте поточну відповідь і side effects до перенесення коду. Потім замінюйте один модуль або інтеграцію за раз, залишаючи оборотний route або feature switch.
Плануйте деплой з урахуванням worker-ів і messages
У Symfony є деталь, якої може не бути у звичайному HTTP-деплої: worker-и довгоживучі. Новий реліз може містити змінений message class або handler, а старий worker ще тримає попередній код. Перезапускайте worker-и під час релізу, використовуйте сумісні зміни messages під час rolling deploy і стежте за failure transport після активації.
Міграції бази спочатку мають бути additive. Спершу додайте колонку або таблицю, випустіть код, який читає обидві версії, зробіть backfill, а потім видаліть compatibility code в наступному релізі. Так rollback не залежить від стану бази, якого вже немає.
Що цей підхід дає бізнесу
Модульність знижує ціну паралельної роботи й прояснює відповідальність. Messenger прибирає повільну або ненадійну роботу з user request. Adapter-и роблять заміну провайдера контрольованою. Явна конфігурація та перезапуск worker-ів зменшують клас інцидентів “працює лише в одному середовищі”. Ці переваги практичні й не вимагають обіцяти, що Symfony прибере всю складність.
Коли Laravel або окремі компоненти підходять краще
Не варто обирати Symfony автоматично. Laravel може бути швидшим для продукту, якому потрібні його конвенції та інтегровані інструменти. Меншій системі можуть знадобитися лише Symfony components — HttpClient, Messenger або Serializer — без повного фреймворку. Правильна відповідь — найменший набір меж, який зберігає бізнес-правила ясними й операційні ризики видимими.
Опис послуги — на сторінці Розробка на Symfony. Якщо ви обираєте між Symfony, Laravel або окремими компонентами, почніть із доменів, failure modes інтеграцій і життєвого циклу worker-ів.
Категорії
Останні пости
Архів