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