<? phpukraine СПІВБЕСІДИ
Пошук по платформі
LARAVEL · SENIOR

Які підходи до мультитенантності в Laravel і як обирати між ними?

Колонка `tenant_id` дає спільні міграції та один пул зʼєднань, але ізоляція живе в кожному `WHERE`, і будь-який забутий фільтр стає витоком. База (або схема) на тенанта переносить межу на рівень конекшна ціною N міграцій, N бекапів і пулу зʼєднань. Глобальний scope закриває лише читання через Eloquent: `DB::table()`, правила `unique`/`exists` і відновлення моделі в черзі його не бачать, а кеш, файли, сесії та scheduler треба розводити по тенантах окремо.

Замовник хоче, щоб дані його компанії лежали окремо від інших. Що це змінює в проєкті, крім конфіга?
Менеджер відкрив список рахунків і побачив там чужу компанію. Де шукати причину, якщо на моделі висить глобальний scope?
Job відпрацював у черзі й надіслав лист не тому тенанту. Чому scope не врятував?
300 тенантів, у кожного своя база. Як ви котите міграцію і що робите, коли вона впала на 180-му?
Multi-tenancy tenant_id global scope черги кеш Postgres RLS

Варіантів насправді два, і відрізняються вони не місцем зберігання рядків, а тим, хто відповідає за межу. Спільна таблиця з колонкою tenant_id тримає межу в коді: одна база, один пул зʼєднань, одна міграція на всіх, звіти по всіх клієнтах пишуться звичайним GROUP BY, а новий тенант коштує один INSERT. Плата - будь-який запит, у якому забули фільтр, показує чужі дані, і жодна помилка бази вас про це не попередить. Окрема база на тенанта переносить межу на рівень конекшна: помилитись у WHERE вже не смертельно, бекап і видалення клієнта стають одним файлом і одним DROP DATABASE, шумний сусід не з'їдає буферний пул решти. Плата теж конкретна: N міграцій на кожен реліз, N бекапів, зʼєднання (у MySQL max_connections за замовчуванням 151, і 20 воркерів з відкритими конекшнами на десятки баз вичерпують його швидше за користувачів) і повна неможливість зробити крос-тенантний JOIN для аналітики. У Postgres є середній варіант, схема на тенанта: одна база й один конекшн, перемикання через search_path, pg_dump --schema на клієнта. Але при кількох тисячах схем системний каталог розростається (кожна схема множить рядки в pg_class), і pg_dump усієї бази та autovacuum починають помітно страждати. Практичне правило: тисячі дрібних тенантів - спільна таблиця, десятки великих корпоративних із вимогою ізоляції в контракті - окремі бази, а між ними живе гібрид, де тенантів шардять по кількох базах, і всередині кожної все одно є tenant_id.

Глобальний scope у single-database схемі - це Scope з методом apply(), який чіпляють у booted() через addGlobalScope() або атрибутом #[ScopedBy(TenantScope::class)]. Він додає where tenant_id = ? у кожен запит, побудований через newQuery(), і цього достатньо рівно доти, доки весь доступ до даних іде через Eloquent. Дірок три, і всі трапляються в реальному коді. Перша: DB::table('invoices') в адмінському звіті чи в експорті будує query builder напряму, scope туди не потрапляє; так само Model::insert() обходить і scope, і події моделі, тому рядки лягають без tenant_id. Друга: правила валідації unique і exists виконуються через DatabasePresenceVerifier, який усередині робить $this->db->connection()->table($table)->useWritePdo(), тобто перевіряє всю таблицю. Симптом тут не витік, а навпаки, «не можу зареєструватись», бо такий email уже є в чужої компанії. Третя: SerializesModels відновлює модель у воркері викликом newQueryForRestoration(), який повертає newQueryWithoutScopes()->whereKey($id), тож job дістане запис незалежно від того, чий контекст зараз активний. І окремо про поведінку без контексту: якщо apply() при порожньому тенанті просто нічого не додає, то будь-яка artisan-команда перетворює scope на no-op. Краще кидати виняток; тоді помилка знаходиться на першому ж прогоні, а не в саппорт-тікеті.

Записи scope не контролює взагалі, тому поруч завжди йде хук creating, який проставляє tenant_id, і композитний унікальний індекс (tenant_id, email) замість звичайного unique. Про зовнішні ключі згадують рідше: FOREIGN KEY (invoice_id) не забороняє привʼязати рядок до рахунку іншого тенанта, бо база про тенантів нічого не знає. Хто хоче гарантій від СУБД, у Postgres вмикає row level security і політику на current_setting('app.tenant_id'), а застосунок ходить роллю без BYPASSRLS; тоді навіть raw-запит фізично не побачить чужого. Коштує це теж не нуль: політика додається до кожного плану, а разом із PgBouncer у transaction pooling треба уважно вибирати між SET і SET LOCAL. У MySQL такого механізму немає, там залишається дисципліна коду плюс тести.

Далі починається частина, яку на співбесіді провалюють найчастіше, бо думають лише про базу. Кеш: ключ dashboard:stats без тенанта віддасть перший прогрітий результат усім, тому або префікс на тенанта (cache.prefix при ініціалізації контексту, RedisStore::setPrefix()), або теги - але теги живуть тільки на Redis і Memcached, на file і database виклик Cache::tags() кидає BadMethodCallException. Пам'ятайте й про те, що cache:clear на Redis робить flushdb, тобто виносить кеш усіх тенантів разом із сесіями, якщо вони на тій самій конекшні. Черги: у payload має лежати tenant_id, а не сподівання на контекст; централізовано це роблять через Queue::createPayloadUsing() плюс слухач JobProcessing, який ініціалізує тенанта перед handle() (у stancl/tenancy це QueueTenancyBootstrapper) - і ця ж логіка потрібна для ретраїв та failed(). Файли: корінь диска або S3-префікс на тенанта, а не тенант в імені файлу; окремо тримайте в голові, що public диск у Laravel один і симлінк на нього теж один, тому приватні документи мають жити на приватному диску з temporaryUrl(). Плюс дрібніше: сесії, broadcast-канали, налаштування пошти, а ще scheduler, який за замовчуванням крутиться в центральному контексті й для тенантських задач мусить перебирати тенантів явно. Під Octane все це загострюється, бо конфіг, конекшни та резолвнуті синглтони переживають запит: те, що ви поставили в config(['database.connections.tenant.database' => ...]), лишиться там і для наступного клієнта, якщо не скинути стан у RequestTerminated або не перерахувати біндінги в octane.flush.

Пакети закривають рутину, але не рішення. stancl/tenancy вміє обидві моделі й приносить набір bootstrapper-ів (база, кеш, файли, черги, Redis) плюс tenants:migrate; spatie/laravel-multitenancy мінімалістичніший, будується на Tenant::makeCurrent(), задачах (SwitchTenantDatabaseTask, PrefixCacheTask) і команді tenants:artisan. Головного питання жоден із них за вас не вирішує: як ви доводите, що ізоляція працює. Відповідь, за яку ставлять плюс: ізоляція перевіряється тестами як звичайна фіча. Архітектурний тест, що кожна модель із колонкою tenant_id має відповідний трейт; функціональний тест на кожен ендпоїнт, який під тенантом B тягне ідентифікатор тенанта A і чекає 404; окремі тести на місця, де scope не діє. Якщо в проєкті цього немає, то tenant_id дає ілюзію безпеки, і рано чи пізно хтось напише звіт через DB::table().

final class TenantScope implements Scope
{
    public function apply(Builder $builder, Model $model): void
    {
        // Порожній контекст (консоль, cron, worker) - це помилка, а не «показати все».
        $tenantId = TenantContext::currentId()
            ?? throw new MissingTenantContext($model::class);

        // qualifyColumn, бо в join-ах tenant_id є в кількох таблицях.
        $builder->where($model->qualifyColumn('tenant_id'), $tenantId);
    }
}

#[ScopedBy(TenantScope::class)]
final class Invoice extends Model
{
    protected static function booted(): void
    {
        // Scope фільтрує читання; запис він не чіпає взагалі.
        static::creating(static function (self $invoice): void {
            $invoice->tenant_id ??= TenantContext::currentId();
        });
    }
}

final class SendInvoiceEmail implements ShouldQueue
{
    // Везти модель не можна: SerializesModels відновлює її через
    // newQueryWithoutScopes(), тож scope у черзі нічого не гарантує.
    public function __construct(
        private readonly int $tenantId,
        private readonly int $invoiceId,
    ) {}

    public function handle(TenantContext $context): void
    {
        // Ставить конекшн, префікс кешу й корінь диска; без цього job
        // доробить у контексті тенанта, якого worker обробляв до нього.
        $context->use($this->tenantId);

        $invoice = Invoice::findOrFail($this->invoiceId);
        Mail::to($invoice->billing_email)->send(new InvoiceIssued($invoice));
    }
}
Що вибір іде за двома осями: де лежать рядки (спільна таблиця, схема, окрема база) і хто стежить за межею (код застосунку, права в БД, окремий конекшн); дешевизна `tenant_id` оплачується тим, що ізоляція тримається на дисципліні розробників.
Що глобальний scope додає `where tenant_id = ?` лише в запити, побудовані через `newQuery()`, тому `DB::table()`, `Model::insert()`, `unique`/`exists` у валідації та `SerializesModels` (він відновлює модель через `newQueryWithoutScopes()`) проходять повз нього.
Що scope фільтрує читання, а не запис: без хука на `creating` рядок збережеться з чужим або порожнім `tenant_id`, а унікальність треба закривати композитним індексом `(tenant_id, email)`, не просто `unique`.
Що scope без контексту тенанта має падати, а не мовчки віддавати все: у консолі, cron і чергах контексту немає за замовчуванням.
Що кеш, файли, сесії, broadcast-канали й scheduler ізолюються окремо від бази: префікс кешу або теги, корінь диска чи S3-префікс на тенанта, `tenant_id` у payload черги.
Що база на тенанта вирішує проблему витоку, але створює операційну: міграції на N баз, N бекапів, ліміт зʼєднань і неможливість зробити крос-тенантний JOIN для звітів.
Вважати, що глобальний scope закриває питання безпеки: адмінський звіт на `DB::table('invoices')` або на `withoutGlobalScope()` віддасть усе, і жоден тест на модель це не зловить.
Писати `unique:users,email` у Form Request: правило йде через `DatabasePresenceVerifier`, тобто `DB::table(...)->useWritePdo()`, тому воно перевіряє унікальність по всіх тенантах одразу і не пускає нового користувача, бо такий email є в чужій компанії.
Дозволяти scope тихо повертати всі рядки, коли контексту немає: перший же `php artisan` або worker без ініціалізації тенанта перетворює фільтр на no-op.
Передавати в job модель і покладатися на scope: `SerializesModels` відновить її через `newQueryWithoutScopes()`, а `handle()` виконається в контексті того тенанта, який worker обробляв попереднім.
Ставити один префікс кешу на всіх і чистити кеш через `Cache::flush()`: на Redis це `flushdb`, тобто виносить кеш усіх тенантів, а часто ще й сесії, якщо вони на тій самій конекшні.
Тримати `filesystems.disks.public.root` спільним, а розводити тенантів лише в іменах файлів: один `public/storage` симлінк плюс передбачувані імена дають прямий доступ до чужих документів повз застосунок.
Робити базу на тенанта і забути про `max_connections`: 20 воркерів × 300 конекшнів у пулі кладуть MySQL швидше, ніж навантаження від користувачів.
Міняти `config(['database.connections.tenant.database' => ...])` під Octane без скидання стану: конекшн і резолвнуті синглтони живуть між запитами, і наступний запит іде в базу попереднього тенанта.
ПОРАДА

Почніть не з пакета, а з двох питань: чи є в контракті вимога фізичної ізоляції та чи потрібні крос-тенантні звіти. Далі сформулюйте межу так: `tenant_id` означає, що ізоляція - це властивість коду, тому її треба тестувати як фічу, а не сподіватись на scope. Сильний фінал - назвати три місця, де scope не працює (raw-запити, валідація `unique`/`exists`, відновлення моделі в черзі), і сказати, чим ви їх закриваєте: композитні індекси, RLS у Postgres і тест, який під тенантом B тягне ідентифікатори тенанта A і чекає 404.

оновлено 6 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Валідація не має нічого спільного з Eloquent: `DatabasePresenceVerifier::table()` бере `DB::connection()->table($table)->useWritePdo()`, тому `unique:users,email` перевіряє всю таблицю і його треба звужувати вручну через `Rule::unique('users')->where('tenant_id', $id)`. Відновлення моделі в черзі йде через `newQueryForRestoration()`, який усередині викликає `newQueryWithoutScopes()`, тож на scope у job покладатись не можна. Теги кешу підтримують лише Redis і Memcached, на `file` і `database` виклик `Cache::tags()` кидає `BadMethodCallException`. А конфіг конекшна до бази не має жодного стосунку до `cache.prefix` і кореня диска: кеш, файли й сесії розводяться окремо навіть тоді, коли база в кожного тенанта своя.