Мультиарендность в Laravel: чек-лист перед выкаткой
Один запрос из тенанта A вернул данные тенанта B — классический провал изоляции. Чек-лист, что проверить в Laravel-приложении с несколькими арендаторами, прежде чем пустить в прод.

Утечка данных между арендаторами — самая дорогая ошибка в SaaS. Не падение сервиса, не медленный отчёт, а именно ситуация, когда клиент A видит заказы клиента B. Обнаруживается обычно постфактум, через обращение в поддержку, и стоит не денег, а репутации. В Laravel мультиарендность собирается из нескольких слоёв — определение тенанта, изоляция БД, изоляция кеша и очередей, файлы — и дырка хотя бы в одном сводит на нет остальные.
Этот материал — не введение в мультиарендность и не сравнение стратегий «одна БД против БД на тенанта». Это чек-лист для ревью и приёмки: что конкретно проверить в коде и конфиге Laravel 11.x, прежде чем открыть регистрацию второго клиента. Пакет stancl/tenancy берём как ориентир, но большинство пунктов не привязано к нему.
Слой 1. Определение тенанта
Всё начинается с того, как приложение понимает, чей запрос обрабатывает. Три распространённых способа: поддомен (acme.app.com), путь (app.com/acme), заголовок или домен целиком. Ошибка здесь означает, что тенант либо не определится, либо определится чужой.
- Тенант определяется до бизнес-логики, а не внутри контроллера. Идентификация должна жить в middleware, который выполняется раньше всего, что читает данные. Если контроллер сам достаёт
tenant_idиз запроса — рано или поздно кто-то забудет это сделать в новом методе. - Нет тенанта — нет доступа. Проверьте, что при неизвестном поддомене или отсутствии идентификатора запрос падает с 404, а не проваливается в код с
tenant = null. Именноnull-тенант чаще всего и приводит к запросам без фильтра. - Центральные (landing, регистрация, биллинг) и тенантские маршруты разведены. В
routes/они должны быть в разных файлах с разными middleware-группами, чтобы регистрация нового клиента случайно не попала под тенантский контекст.
Проверка
Откройте несуществующий поддомен — должна быть 404 или редирект на центральный домен, но не рабочая страница с пустыми данными.
Слой 2. Изоляция базы данных
Здесь два принципиально разных подхода, и цена у них разная.
| Подход | Изоляция | Цена |
|---|---|---|
Одна БД, колонка tenant_id | Слабее: всё держится на фильтрах в запросах | Просто масштабировать, дёшево по ресурсам, но одна забытая выборка = утечка |
| Отдельная БД (или схема) на тенанта | Сильнее: физически разные соединения | Сложнее миграции и бэкапы, дороже при тысячах мелких тенантов |
При подходе с общей БД ключевой пункт — глобальный scope на всех моделях с данными тенанта. Если фильтрацию приходится дописывать в каждом запросе руками, утечка — вопрос времени.
<?php
namespace App\Models\Concerns;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
// Глобальный scope: подмешивает where tenant_id ко всем запросам модели
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
// app('currentTenant') должен быть установлен middleware до запросов
if ($tenant = app()->bound('currentTenant') ? app('currentTenant') : null) {
$builder->where($model->getTable() . '.tenant_id', $tenant->id);
}
}
}Дальше scope цепляется к модели через метод booted():
<?php
namespace App\Models;
use App\Models\Concerns\TenantScope;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TenantScope);
// Проставляем tenant_id при создании, чтобы не полагаться на разработчика
static::creating(function (Order $order) {
if (empty($order->tenant_id) && app()->bound('currentTenant')) {
$order->tenant_id = app('currentTenant')->id;
}
});
}
}Пункты для этого слоя:
- Каждая тенантская таблица имеет
tenant_idи индекс по нему. Без индекса общая БД начнёт тормозить на первых десятках тысяч строк. - Есть осознанный список запросов, которые обходят scope. Иногда админке нужно видеть все тенанты. Такие места должны использовать явный
withoutGlobalScopeи быть наперечёт, а не разбросаны по коду. - Raw-запросы (
DB::table,DB::select) проверены отдельно. Глобальный scope Eloquent на них не действует — здесь фильтр по тенанту дописывается руками. Это первое место, где течёт.
Глобальный scope защищает Eloquent, но не защищает вас отDB::table('orders')->get(). Любой обход ORM — это ручная ответственность заtenant_id.
Слой 3. Кеш, сессии и очереди
Про этот слой забывают чаще всего, потому что БД изолировали и успокоились. А кеш и очереди общие.
- Ключи кеша префиксуются тенантом. Если тенант A положил
cache('dashboard'), а тенант B читает тот же ключ — он получит чужой дашборд. Ключ должен содержать идентификатор тенанта либо кеш должен переключаться на изолированное хранилище в middleware. - Задачи в очереди несут тенанта в payload. Воркер запускается вне HTTP-контекста,
currentTenantв нём не установлен. Если джоба читает данные тенанта, она должна принять его id и восстановить контекст вhandle(), иначе scope сработает сnull-тенантом и либо ничего не найдёт, либо (без scope) заденет всех. - Сессии не пересекаются между поддоменами. Проверьте
config/session.php: параметр домена и то, как ставится cookie. Общая cookie на весь*.app.comможет протащить авторизацию с одного тенанта на другой.
Проверка очередей
Запустите джобу из-под тенанта A, посмотрите в лог, с каким tenant_id она отработала. Если контекст восстанавливается автоматически (например, stancl/tenancy умеет сериализовать тенанта в задачу) — убедитесь, что это включено, а не выключено дефолтом.
Слой 4. Файлы и хранилища
- Пути загрузок содержат тенанта.
storage/tenants/{id}/...вместо общей папки, иначе один клиент перезапишет файл другого по совпадению имени. - Публичные ссылки не позволяют перебором дойти до чужого файла. Если файлы раздаются по прямому пути, а имя — предсказуемое, изоляция БД не помогает.
Что проверять тестами, а не глазами
Ревью ловит забытый фильтр в существующем коде. Новый код через месяц напишет другой человек. Поэтому изоляцию закрывают тестом, который переживёт рефакторинг:
- тест создаёт данные под тенантом A и тенантом B;
- переключается в контекст A;
- запрашивает список — и проверяет, что в выборке нет ни одной записи B.
Такой тест на пару ключевых моделей стоит дешевле, чем разбор инцидента с утечкой, и падает сразу, как только кто-то добавит запрос в обход scope.
Где это ломается на практике
- Eager loading и связи.
$order->itemsтянет связанную модель. Если уOrderItemнет своего scope, аtenant_idон не хранит — связь может увести за пределы тенанта через некорректныйorderId. Проверяйте, что цепочки связей не выходят за границу. - Артизан-команды и планировщик. Крон работает без тенанта. Команда, которая ходит по всем тенантам, должна явно устанавливать контекст в цикле, а не рассчитывать на глобальный.
- Кеш конфигурации и роутов.
php artisan config:cacheфиксирует значения на момент кеширования. Если конфиг соединения зависит от тенанта — кешировать его нельзя, иначе все получат настройки одного.
Что делать дальше
Пройдите чек-лист снизу вверх, начиная с самого забываемого — очередей и кеша, потому что БД обычно уже защищена. Затем напишите тест на изоляцию хотя бы для двух моделей и включите его в CI. Точное поведение автоматического переключения тенанта в очередях и командах сверяйте с документацией используемого пакета для вашей версии — детали stancl/tenancy и других решений отличаются и меняются между мажорными релизами.
Отдельные темы, которые сюда не вошли: перенос тенанта между БД, шардирование и биллинг по потреблению — каждая тянет на свой разбор.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Eloquent: Global Scopes — Laravel Documentation, stancl/tenancy — Automatic multi-tenancy for Laravel
Частые вопросы
Хватит ли одного глобального scope, чтобы считать данные изолированными?
Что выбрать: одну БД с tenant_id или отдельную БД на тенанта?
У меня другая версия Laravel, не 11.x — код подойдёт?
Джоба в очереди читает не те данные или ничего не находит — почему?
Как быстро убедиться, что утечки между тенантами нет?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.