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

Мультиарендное приложение работает нормально до первого инцидента: клиент А открывает список заказов и видит там строку клиента Б. Дальше — панические звонки, разбор логов и вопрос «как это вообще стало возможным». В Laravel большинство таких утечек происходит не из-за экзотики, а из-за трёх мест, где изоляция арендатора теряется по умолчанию: глобальные скоупы, кеш и очереди. Разберём эти места на уровне кода и покажем, чем платит каждый подход к разделению данных.
Статья для разработчиков, которые уже пишут SaaS на Laravel 11.x и упёрлись в вопрос, как не смешать данные арендаторов. Инвалидация кеша между тенантами — отдельная большая тема, здесь она затронута только там, где создаёт утечку. Провижининг новых баз данных под single-database-per-tenant тоже за рамками.
Три модели изоляции и их цена
Прежде чем ловить баги, определитесь с моделью. От неё зависит, где именно у вас будут протекать данные.
| Модель | Как хранятся данные | Что выигрываете | Чем платите |
|---|---|---|---|
| Одна БД, колонка tenant_id | Все арендаторы в общих таблицах, разделены по столбцу | Простой деплой, одна миграция, дешёвый хостинг | Любой забытый where — утечка. Тяжело удалить одного арендатора |
| Отдельная схема на арендатора | PostgreSQL schema или префикс таблиц на клиента | Физическое разделение, простое удаление | Миграции по всем схемам, сложнее агрегатная аналитика |
| Отдельная БД на арендатора | Своё подключение для каждого клиента | Максимальная изоляция, независимый бэкап | Провижининг БД, миграции по сотням баз, дорогая инфраструктура |
Большинство ошибок ниже касаются первой модели — она же самая распространённая на старте. Именно в ней изоляция держится не на инфраструктуре, а на дисциплине кода.
Ошибка 1: полагаться на ручной where в каждом запросе
Классический старт: в каждом контроллере пишут Order::where('tenant_id', auth()->user()->tenant_id)->get(). Работает ровно до того момента, когда кто-то в новом методе про where забудет. А забудет обязательно — в связанных моделях, в отчёте, в фоновой команде.
Правильный уровень защиты — глобальный скоуп1, который Eloquent подставляет в каждый запрос модели автоматически.
<?php
// app/Models/Scopes/TenantScope.php — Laravel 11.x
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
{
// app(Tenant::class) — текущий арендатор, разрешённый в контейнере
if ($tenantId = app('current_tenant_id')) {
$builder->where($model->getTable() . '.tenant_id', $tenantId);
}
}
}Подключаете скоуп в базовой модели или через атрибут в Laravel 11:
<?php
// app/Models/Order.php
use App\Models\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Model;
#[ScopedBy([TenantScope::class])]
class Order extends Model
{
// при создании автоматически проставляем tenant_id
protected static function booted(): void
{
static::creating(function (Order $order) {
$order->tenant_id ??= app('current_tenant_id');
});
}
}Теперь Order::all() уже отфильтрован. Но у скоупа есть острый край, который сам становится ошибкой 2.
Ошибка 2: скоуп молча отключается там, где не ждёшь
Глобальный скоуп применяется не везде. Три места, где он не сработает и данные утекут:
- withoutGlobalScope / withoutGlobalScopes. Кто-то вызвал его в отчёте «чтобы посчитать всё» и забыл вернуть фильтр. Grep по проекту на
withoutGlobalScopeдолжен быть частью ревью. - Прямые запросы через
DB::table('orders'). Query Builder не знает про Eloquent-скоупы вообще. Любой сырой запрос обходит изоляцию — здесьwhere('tenant_id', ...)ставится руками, иначе никак. - Связи, объявленные на не-скоупнутой модели. Если
UserбезTenantScopeимеетhasMany(Order::class), то$user->ordersподтянет скоуп корректно, но обратная выборка «все пользователи по заказу» может увести за пределы арендатора, если промежуточная модель не защищена.
Глобальный скоуп — это не забор, а привычка фреймворка. ОдинDB::table()в отчёте пробивает его так же легко, как забытыйwhereпробивал ручную фильтрацию. Разница только в том, что теперь дыра одна, а не в каждом методе.
Ошибка 3: общий кеш на всех арендаторов
Вы кешируете список категорий по ключу categories. Первый арендатор прогрел кеш — второй получил его категории. Утечка через кеш коварнее, чем через БД: она не видна в запросах и всплывает только под трафиком.
Ключ кеша должен содержать идентификатор арендатора:
<?php
// вместо Cache::remember('categories', ...)
use Illuminate\Support\Facades\Cache;
$tenantId = app('current_tenant_id');
$categories = Cache::remember(
"tenant:{$tenantId}:categories",
now()->addMinutes(30),
fn () => Category::all()
);Если арендаторов много, а Redis один — рассмотрите префикс подключения. В config/database.php у Redis-подключения есть параметр prefix, который можно менять в рантайме при разрешении арендатора. Тот же принцип касается сессий: два арендатора не должны делить пространство ключей сессии, если по домену они попадают в одно приложение.
Ошибка 4: очереди теряют контекст арендатора
Задача попала в очередь в контексте арендатора А. Воркер поднял её через час — контекста уже нет, app('current_tenant_id') пуст или, хуже, содержит арендатора предыдущей задачи. Скоуп из ошибки 1 в этот момент либо не фильтрует ничего, либо фильтрует не того.
Контекст надо сериализовать вместе с задачей и восстанавливать в воркере:
<?php
// app/Jobs/GenerateReport.php
namespace App\Jobs;
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 function handle(): void
{
// восстанавливаем контекст ДО любых запросов к БД
app()->instance('current_tenant_id', $this->tenantId);
// теперь Order::all() снова изолирован
$orders = \App\Models\Order::all();
// ... формирование отчёта
}
}Диспатчите с явной передачей арендатора: GenerateReport::dispatch(app('current_tenant_id')). Если у вас много задач, вынесите восстановление в middleware очереди или в общий базовый класс, чтобы не дублировать строку в каждом handle.
Ошибка 5: определять арендатора после того, как приложение уже что-то сделало
Арендатора обычно разрешают по поддомену или заголовку в middleware. Если это middleware стоит в группе web после аутентификации, а какой-то ранний сервис-провайдер уже дёрнул БД — он сделает это без контекста. Порядок важен: разрешение арендатора должно случиться максимально рано и до бизнес-логики.
Проверьте, что middleware разрешения арендатора зарегистрирован в начале группы, а не в конце. В Laravel 11 порядок настраивается в bootstrap/app.php через $middleware->prependToGroup(...) или явным указанием приоритета.
Если не сработало
- Видите чужие данные в списке, но в БД
tenant_idпроставлен верно. Скорее всего запрос идёт черезDB::table()или через связь на не-скоупнутой модели. Включите логирование SQL (DB::enableQueryLog()) и найдите запрос без условия поtenant_id. - В фоновой задаче данные не того арендатора. Контекст не восстановлен в
handleили воркер переиспользует состояние между задачами. Убедитесь, чтоcurrent_tenant_idзадаётся в начале каждогоhandle, а не берётся из окружения предыдущей задачи. - После обновления страницы данные то верные, то чужие. Утечка через кеш. Ищите ключи
Cache::без идентификатора арендатора. - Ошибка
Integrity constraint violation: tenant_id cannot be nullпри создании. Контекст не разрешён на момент вставки — либо запрос идёт из консольной команды без арендатора, либо middleware не отработал.
Что делать дальше
Соберите тест, который под контекстом арендатора А создаёт запись, переключает контекст на Б и проверяет, что Order::count() вернул ноль. Такой тест ловит регресс по изоляции надёжнее любого ревью. Отдельно прогоните grep по проекту на withoutGlobalScope и DB::table — это ваш список мест, где изоляцию нужно проверять руками. Если проект перерастает модель «одна БД с tenant_id», посмотрите готовые пакеты для мультиарендности, но помните: любой пакет закрывает запросы к БД и редко закрывает кеш и очереди — эти два места остаются на вас.
Prompt-инженер: Идеальные запросы для Midjourney, ChatGPT и других моделей.
Спросить за 15 ₽Источники: Eloquent: Global Scopes — Laravel Documentation, Queues — Laravel Documentation
Частые вопросы
У меня Laravel 10, а не 11 — код подойдёт?
Стоит ли брать готовый пакет вместо самописного скоупа?
Что делать с консольными командами и планировщиком?
Насколько тяжело добавить условие tenant_id к каждому запросу для производительности?
Как протестировать, что изоляция реально работает?
Скоуп фильтрует выборку, но при обновлении можно ли случайно изменить чужую запись?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.