Eloquent-скоупы и переиспользование запросов: чек-лист
Скоупы в Eloquent удобны, пока не начинают тихо ломать фильтрацию, ORDER BY и eager loading. Разбираем, где именно, и собираем чек-лист перед выкаткой.

Скоуп, который «просто добавляет where active = 1», однажды приходит в отчёт, где нужны все записи, включая неактивные, — и данные молча теряются. Другой скоуп навешивает orderBy, из-за чего ->count() в другом месте отдаёт неверное число или падает на некоторых СУБД. Это не баги Laravel: скоупы делают ровно то, что вы написали, но их поведение неочевидно при переиспользовании. Ниже — разбор, где скоупы кусаются, и чек-лист, который стоит прогнать перед продакшеном. Материал для Laravel 10.x и 11.x; отличия версий отмечены отдельно.
Два вида скоупов и почему их путают
В Eloquent есть локальные и глобальные скоупы, и это разные механизмы с разной ценой.
- Локальный скоуп — метод модели с префиксом
scope, который вы вызываете явно:Post::published(). Он ничего не делает, пока его не позвали. - Глобальный скоуп — класс, реализующий
Illuminate\Database\Eloquent\Scope, применяется ко всем запросам модели автоматически. Именно он источник большинства сюрпризов: вы можете забыть, что он есть.
Локальный скоуп определяется так (Laravel 10.x/11.x):
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
// Возвращаем Builder, а не вызываем get() — иначе цепочку не продолжить
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published');
}
public function scopeOfType(Builder $query, string $type): Builder
{
return $query->where('type', $type);
}
}
Вызов: Post::published()->ofType('article')->get(). Параметр $type подставляется как второй аргумент — первым всегда идёт Builder.
Где ломается первый раз: скоуп с orderBy
Соблазн зашить сортировку прямо в скоуп велик: return $query->where('status', 'published')->orderBy('published_at', 'desc'). Проблема вылезает, когда этот же скоуп используют для агрегата:
// Скоуп тянет за собой ORDER BY, который агрегату не нужен
$count = Post::published()->count();
На MySQL это обычно проходит, но лишний ORDER BY в подзапросе замедляет план или отбрасывается оптимизатором с предупреждением. На PostgreSQL ORDER BY по колонке, которой нет в GROUP BY, при агрегации даёт ошибку. Держите фильтрацию в скоупе, а сортировку — на месте вызова. Если сортировка неотделима от смысла выборки, сделайте отдельный скоуп latest() и вызывайте его явно там, где он нужен.
Глобальные скоупы: удобство, которое прячет строки
Классический пример — мягкое удаление. Трейт SoftDeletes под капотом регистрирует глобальный скоуп, который добавляет where deleted_at is null в каждый запрос. Пока вы об этом помните, всё хорошо. Проблема начинается, когда свой глобальный скоуп фильтрует по бизнес-условию — например, «только записи текущего тенанта».
Глобальный скоуп — это невидимый WHERE, который применяется даже там, где вы его не ждёте: в миграциях данных, в командах обслуживания, в отчётах по всей базе. Тот, кто пишет запрос через полгода, скоупа в коде запроса не увидит.
Регистрация глобального скоупа через метод booted (Laravel 10.x/11.x):
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Invoice extends Model
{
protected static function booted(): void
{
// Применяется ко ВСЕМ запросам модели Invoice
static::addGlobalScope('paid', function (Builder $query) {
$query->where('status', 'paid');
});
}
}
Отключить его в конкретном запросе можно так:
// Снять один именованный скоуп
$all = Invoice::withoutGlobalScope('paid')->get();
// Снять все глобальные скоупы разом
$raw = Invoice::withoutGlobalScopes()->get();
Правило простое: если глобальный скоуп меняет набор строк по бизнес-логике, задокументируйте способ его отключения рядом с определением. Тот, кто будет чинить отчёт в три часа ночи, скажет спасибо.
Ловушка с orWhere внутри скоупа
Самая частая уязвимость переиспользуемых скоупов — потеря группировки условий. Скоуп добавляет orWhere, а при комбинации с внешним where логика ломается:
// В скоупе
public function scopePopular(Builder $query): Builder
{
return $query->where('views', '>', 1000)
->orWhere('featured', true);
}
// Вызов
Post::where('status', 'published')->popular()->get();
Получится WHERE status = 'published' AND views > 1000 OR featured = true, и последнее условие «протекает» наружу — вернутся все featured-записи любого статуса. Оборачивайте orWhere внутри скоупа в замыкание, чтобы условие было изолированным:
public function scopePopular(Builder $query): Builder
{
return $query->where(function (Builder $q) {
$q->where('views', '>', 1000)
->orWhere('featured', true);
});
}
Теперь получится WHERE status = 'published' AND (views > 1000 OR featured = true) — как и задумано.
Скоупы и eager loading: N+1 из ниоткуда
Скоуп, который сам подгружает связи (->with('author')), удобен, пока не встречается с ->withCount() или ручным with на месте вызова. Дублирующая загрузка связи не всегда фатальна, но при load уже загруженной модели вы получаете лишний запрос. Держите with вне скоупа фильтрации: скоуп отвечает за какие строки, а eager loading — за какие связи. Инвалидация и прогрев кеша запросов — отдельная тема, здесь её не трогаем.
Локальный против глобального: когда что
| Критерий | Локальный скоуп | Глобальный скоуп |
|---|---|---|
| Когда применяется | Только при явном вызове | Ко всем запросам модели |
| Видимость в коде запроса | Виден (Post::published()) | Невидим |
| Риск случайной потери строк | Низкий | Высокий |
| Отключается | Просто не вызываете | withoutGlobalScope() |
| Для чего брать | Опциональные фильтры, комбинируемые условия | Мультитенантность, мягкое удаление, срез, обязательный всегда |
Критерий выбора: если условие должно действовать всегда и без исключений (данные другого тенанта в принципе нельзя показывать) — глобальный скоуп оправдан. Если это удобный, но опциональный фильтр — только локальный, чтобы не удивлять коллег.
Чек-лист перед продакшеном
- В скоупе нет
orderByбез явной причины. Проверьте, что скоуп фильтрации не тянет сортировку вcount()и агрегаты. - Каждый
orWhereобёрнут в замыкание. ПрогонитеtoSql()на скоупе в комбинации с внешнимwhereи посмотрите на скобки. - Скоуп возвращает
Builder, а не результат. Никакихget()/first()внутри — иначе цепочка обрывается. - Для каждого глобального скоупа известен способ отключения и он описан в комментарии рядом.
- Eager loading вынесен из скоупов фильтрации. Проверьте, нет ли двойной загрузки связи.
- Проверено на вашей СУБД. Поведение
ORDER BYпри агрегации на MySQL и PostgreSQL отличается — тестируйте на той, что в проде. - Скоупы покрыты тестами на итоговый SQL. Ассертьте
toSql()или результат на фикстурах, а не только «запрос не упал».
Как проверить SQL, не запуская
Быстрая проверка формы запроса без обращения к базе:
// Отдаёт строку SQL с плейсхолдерами
$sql = Post::where('status', 'published')->popular()->toSql();
// А это — реально привязанные значения
$bindings = Post::where('status', 'published')->popular()->getBindings();
Скобки вокруг orWhere в выводе toSql() — самый быстрый способ поймать протекающее условие до того, как его поймает продакшен.
Что делать дальше
Напишите тесты, которые ассертят toSql() для каждого нетривиального скоупа — это дешевле, чем ловить регресс на данных. Включите в дев-окружении логирование запросов (через DB::enableQueryLog() или Laravel Telescope) и просмотрите реальные SQL по ключевым эндпоинтам: часто именно там видно, что глобальный скоуп добавил лишний JOIN или WHERE, о котором никто не помнил. Официальную справку по синтаксису скоупов сверяйте с документацией вашей версии — API между 10.x и 11.x в этой части не менялся, но детали регистрации через ScopedBy-атрибут в 11.x стоит проверить в доке.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Laravel Eloquent: Query Scopes (официальная документация)
Частые вопросы
У меня Laravel 8, синтаксис из статьи подойдёт?
Скоуп с orWhere вернул лишние строки — как быстро понять, где течёт?
Глобальный скоуп ломает миграцию данных — что делать?
Можно ли вызвать get() внутри скоупа, чтобы сразу вернуть данные?
Как тестировать скоупы, не поднимая тяжёлую базу?
Глобальный скоуп замедляет запросы — это нормально?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.