Мультиарендность в Laravel: чек-лист перед продакшеном
Мультиарендное приложение падает не на демо, а на десятом клиенте, когда очередь берёт задачу без контекста арендатора и пишет данные в чужую базу. Разбираем, что проверить перед запуском.

Мультиарендное приложение на Laravel ломается предсказуемо: не на демонстрации, а через месяц после запуска, когда воркер очереди берёт задачу без привязки к арендатору и пишет отчёт клиента A в базу клиента B. Или когда кеш, положенный под ключом settings, отдаёт настройки чужой компании. Утечка данных между арендаторами — это не баг, а инцидент с уведомлением регулятора. Ниже — чек-лист того, что нужно закрыть до продакшена, если вы строите SaaS на Laravel 11.x.
Статья не про выбор стратегии изоляции — про то, что ломается в уже выбранной. Сравнение «одна база с колонкой tenant_id против базы на арендатора» обозначу таблицей, но разбор идёт для обеих моделей одновременно, потому что дыры у них общие: контекст теряется в фоне, а не в контроллере.
Две модели изоляции и где у каждой тонко
Прежде чем идти по чек-листу, зафиксируйте, какую модель вы используете. От этого зависит, какой пункт критичен.
| Критерий | Одна БД, tenant_id в строках | База на арендатора |
|---|---|---|
| Изоляция данных | Логическая: держится на коде | Физическая: держится на подключении |
| Цена ошибки | Забыли фильтр — видно чужое | Забыли переключить connection — пишем не туда |
| Миграции | Одна прогонка | Прогон по каждой базе в цикле |
| Стоимость на клиента | Дешёвая, строки в общих таблицах | Дороже: соединения, дисковое место |
| Бэкап отдельного клиента | Сложный, выборка по tenant_id | Простой, дамп его базы |
Готового «мультиарендного» ядра в Laravel нет — фреймворк даёт подключения и глобальные скоупы, а связывание с арендатором пишете вы или берёте пакет. Популярное решение — пакет stancl/tenancy; он закрывает переключение баз и доменов, но чек-лист ниже актуален и для него, потому что фоновые задачи и кеш вы всё равно настраиваете сами.
Чек-лист перед запуском
1. Глобальный скоуп нельзя обойти случайно
Если вы фильтруете по tenant_id глобальным скоупом, проверьте, что его нельзя незаметно снять. Метод withoutGlobalScopes() и прямые запросы через DB::table() скоуп игнорируют — а именно их пишут в спешке для отчётов и импортов.
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
// tenant() — ваш хелпер, возвращает текущего арендатора
$tenant = tenant();
if ($tenant === null) {
// Нет контекста — не отдаём вообще ничего,
// а не «все строки». Это безопаснее по умолчанию.
$builder->whereRaw('1 = 0');
return;
}
$builder->where($model->getTable() . '.tenant_id', $tenant->id);
}
}Ключевой момент — ветка whereRaw('1 = 0'). Если контекст арендатора не установлен, безопасное поведение — вернуть пустоту, а не всё подряд. Многие делают наоборот и получают утечку в момент, когда контекст по какой-то причине не поднялся.
2. Очереди теряют арендатора
Самый частый источник инцидентов. Джоба сериализуется в одном контексте, а выполняется воркером позже — там текущего арендатора уже нет. Если вы полагаетесь на глобальную переменную tenant(), внутри handle() она будет пустой или, хуже, будет содержать арендатора из соседней задачи, если воркер долгоживущий.
Решение — прокидывать идентификатор арендатора в саму джобу и восстанавливать контекст в начале handle():
<?php
namespace App\Jobs;
use App\Models\Tenant;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class GenerateReport implements ShouldQueue
{
use Dispatchable, Queueable;
public function __construct(public int $tenantId, public int $reportId)
{
// Сериализуем только id, не весь объект арендатора
}
public function handle(): void
{
$tenant = Tenant::findOrFail($this->tenantId);
// Поднимаем контекст руками перед любой работой с данными
app()->instance('currentTenant', $tenant);
// ... дальше вся логика видит правильного арендатора
}
}Проверка на ревью: любая джоба, которая читает или пишет данные арендатора, обязана либо принимать tenantId в конструкторе, либо явно восстанавливать контекст. Джоба без этого — красный флаг.
Правило, которое экономит инциденты: контекст арендатора обязан существовать к моменту первого запроса к данным и обязан отсутствовать по умолчанию. Всё, что «наследует» контекст неявно — очередь, шедулер, консольная команда, — потенциальная точка утечки.
3. Кеш, сессии и файлы — тоже данные арендатора
Строку Cache::get('dashboard_stats') увидят все арендаторы, если ключ общий. Префиксуйте ключи идентификатором:
- Кеш — добавляйте
tenant()->idв ключ или используйте отдельный store с префиксом. Проверьте теги, если чистите кеш по тегам. - Файлы — храните под путём вида
tenants/{id}/, а не в общем каталоге. Настройте отдельный диск вconfig/filesystems.php, если храните у разных клиентов в разных бакетах. - Сессии — при драйвере
databaseубедитесь, что таблица сессий не смешивает арендаторов через общий домен.
4. Шедулер и консольные команды
Планировщик запускается без HTTP-запроса, значит без контекста арендатора. Команда вида php artisan reports:daily в мультиарендном приложении почти всегда должна оборачиваться циклом по арендаторам:
<?php
namespace App\Console\Commands;
use App\Models\Tenant;
use Illuminate\Console\Command;
class DailyReports extends Command
{
protected $signature = 'reports:daily';
public function handle(): int
{
Tenant::query()->each(function (Tenant $tenant) {
// Поднимаем контекст на каждой итерации
app()->instance('currentTenant', $tenant);
// ... логика отчёта для одного арендатора
// Не забыть сбросить кеш модели между итерациями,
// если храните что-то в статике
});
return self::SUCCESS;
}
}5. Миграции при базе на арендатора
Если у каждого арендатора своя база, обычный php artisan migrate прогонит только центральную. Нужен отдельный проход по подключениям арендаторов — либо через пакет (stancl/tenancy даёт команду tenants:migrate), либо своим циклом. Отдельный риск: новый арендатор, созданный после последней миграции, стартует с непромигрированной базой. Создание арендатора должно сразу прогонять миграции для его подключения.
6. Уникальность и внешние ключи
В модели «одна БД» уникальность email больше не глобальная. Индекс unique('email') запретит двум клиентам иметь пользователя с одним адресом. Замените на составной: unique(['tenant_id', 'email']). То же с внешними ключами — проверьте, что связь не может указать на строку чужого арендатора.
Как это тестировать
Ручная проверка одного арендатора ничего не доказывает. Минимальный набор автотестов:
- Создать двух арендаторов, положить каждому по записи. Запросить от имени первого — убедиться, что вторая не видна.
- Диспатчить джобу от арендатора A, выполнить синхронно, проверить, что она писала именно в данные A.
- Положить кеш от арендатора A, прочитать от B — убедиться, что B получает промах, а не значение A.
- Запросить данные без установленного контекста — убедиться, что ответ пустой, а не полная выборка.
Четвёртый пункт ловит самую опасную дыру — поведение по умолчанию, когда контекст не поднялся.
Что делать дальше
Перед запуском прогоните нагрузку с несколькими арендаторами одновременно на долгоживущих воркерах (queue:work, не queue:listen) — именно там всплывает утечка контекста между задачами из-за статического состояния. В проде смотрите на два показателя: долю джоб, упавших с ошибкой отсутствия арендатора, и запросы, где в WHERE нет tenant_id — их можно ловить логированием медленных и «широких» запросов в PostgreSQL или MySQL. Инвалидация кеша между арендаторами и биллинг по потреблению — отдельные темы, здесь их не разбираем.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Laravel Queues — официальная документация, Eloquent: Global Scopes — официальная документация
Частые вопросы
Мне нужен пакет вроде stancl/tenancy или можно обойтись своими средствами?
У меня Laravel 10, а не 11 — код подойдёт?
Что будет, если джоба выполнится без восстановления контекста арендатора?
Я всё настроил, но на долгоживущем воркере данные всё равно иногда путаются между арендаторами. В чём дело?
Как проверить прод на пропущенные фильтры по tenant_id, если приложение уже работает?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.