Eloquent-скоупы: как перестать копировать одинаковые where
Логика фильтрации расползается по контроллерам, и один и тот же where('active', 1) повторяется в десяти местах. Разбираем локальные и глобальные скоупы Eloquent: когда какой брать и чем каждый платит.

Вы открываете третий контроллер за день и снова видите where('status', 'published')->where('deleted_at', null)->orderByDesc('created_at'). Тот же набор условий уже есть в двух других местах, в API-ресурсе и в фоновой команде. Кто-то поменял правило «опубликованный» — добавил проверку даты публикации — и обновил его в четырёх файлах из шести. Два запроса теперь врут. Скоупы Eloquent придуманы ровно против этого: собрать повторяющийся кусок запроса в одно именованное место. Но у локальных и глобальных скоупов разная цена, и выбор между ними — не вкусовщина.
Материал для Laravel 10.x и 11.x — API скоупов между этими версиями не менялся. Инвалидация кеша запросов и мультитенантность через глобальные скоупы — отдельные большие темы, здесь только базовый механизм переиспользования.
Локальный скоуп: именованный кусок запроса
Локальный скоуп — это метод модели с префиксом scope, который принимает Builder и дописывает в него условия. Вызывается без префикса.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class Post extends Model
{
// Опубликованные: статус published и дата публикации в прошлом
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'published')
->where('published_at', '<=', now());
}
// Скоуп с аргументом: посты конкретного автора
public function scopeByAuthor(Builder $query, int $authorId): Builder
{
return $query->where('author_id', $authorId);
}
}Теперь правило «опубликованный» живёт в одном месте:
// Цепляются как обычные методы билдера
$posts = Post::published()->byAuthor(42)->latest()->get();Поменялось определение публикации — правите один метод, и все шесть мест подтягивают изменение. Это главная выгода: не производительность, а то, что бизнес-правило перестаёт дублироваться.
Возвращайте Builder, а не забывайте про return
Скоуп без return работает — Eloquent модифицирует переданный объект по ссылке. Но если вы забудете вернуть билдер и попробуете использовать возвращаемое значение, получите null. Возвращайте Builder всегда и указывайте тип возврата — тогда цепочка предсказуема и IDE подсказывает методы.
Глобальный скоуп: условие, которое нельзя забыть
Локальный скоуп надо явно вызвать. Глобальный применяется ко всем запросам модели автоматически — как SoftDeletes, который сам дописывает whereNull('deleted_at'). Это удобно для условий, которые обязаны быть везде: мягкое удаление, фильтр по текущему тенанту, скрытие черновиков.
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class PublishedScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('status', 'published');
}
}Подключение в модели. В Laravel 11 появился атрибут #[ScopedBy], который читается яснее старого booted():
<?php
namespace App\Models;
use App\Models\Scopes\PublishedScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Model;
#[ScopedBy([PublishedScope::class])]
class Post extends Model
{
//
}Атрибут #[ScopedBy] доступен в Laravel 11.x. В 10.x регистрируйте глобальный скоуп через метод booted() и static::addGlobalScope() — актуальный синтаксис смотрите в разделе Query Scopes официальной документации.
Глобальный скоуп удобен ровно до того момента, когда вам нужны данные вопреки ему. И этот момент наступает всегда: админке нужны черновики, отчёту — удалённые записи. Каждый глобальный скоуп рано или поздно требует способ его отключить.
Как снять глобальный скоуп для одного запроса
Через withoutGlobalScope() с классом скоупа или withoutGlobalScopes() для всех сразу:
use App\Models\Scopes\PublishedScope;
// Все посты, включая черновики
$all = Post::withoutGlobalScope(PublishedScope::class)->get();Чем платит каждый подход
| Критерий | Локальный скоуп | Глобальный скоуп |
|---|---|---|
| Применение | Явно, при вызове | Автоматически ко всем запросам |
| Риск забыть | Есть: программист может не вызвать | Нет: применяется сам |
| Риск скрытого поведения | Низкий: видно в коде запроса | Высокий: условие невидимо в месте вызова |
| Отключение | Не нужно — просто не вызываете | Требует withoutGlobalScope |
| Аргументы | Поддерживает | Не поддерживает напрямую |
Когда что выбирать
- Локальный — правило применяется в части запросов и зависит от контекста: «посты за месяц», «заказы дороже суммы», «пользователи с подтверждённым email». Всё, что имеет аргумент, — только локальный.
- Глобальный — правило обязано быть во всех запросах модели без исключений по умолчанию: мягкое удаление, изоляция по тенанту, скрытие записей с другим языком сайта. Если хоть раз забыть условие означает утечку чужих данных — это глобальный скоуп.
Композиция: скоуп, вызывающий скоуп
Скоупы комбинируются. Внутри одного локального скоупа можно вызывать другой — это способ собрать составное правило из простых:
// В модели Order
public function scopeActive(Builder $query): Builder
{
return $query->whereIn('status', ['paid', 'shipped']);
}
public function scopeRecentActive(Builder $query, int $days = 30): Builder
{
return $query->active()
->where('created_at', '>=', now()->subDays($days));
}Один нюанс про orWhere в скоупах. Если внутри скоупа стоит orWhere, а снаружи уже есть свои условия, приоритет операторов ломает логику: WHERE a = 1 AND b = 2 OR c = 3 — не то, что вы ждали. Оборачивайте содержимое скоупа в замыкание, чтобы условия сгруппировались скобками:
public function scopePopular(Builder $query): Builder
{
return $query->where(function (Builder $q) {
$q->where('views', '>', 1000)
->orWhere('likes', '>', 100);
});
}Что ломается на практике
- Забытый return в локальном скоупе. Запрос отработает, но при попытке продолжить цепочку от результата скоупа получите
Call to a member function ... on null. - orWhere без группировки. Самая тихая ошибка: запрос не падает, а возвращает лишние строки. Проверяется только логами SQL через
toRawSql()или отладчик запросов. - Глобальный скоуп плюс сырой JOIN. Когда вы уходите от Eloquent к
DB::table()или пишете сырой запрос, глобальный скоуп не применяется — фильтрация тенанта пропадает. Сырые запросы к таблицам с глобальным скоупом — источник утечек данных. - Скоуп, который делает eager loading. Вызов
with()внутри скоупа выглядит удобно, но незаметно тянет связи в каждом запросе, где скоуп вызван. Загрузку связей держите в месте вызова, а не прячьте в скоуп.
Что делать дальше
Пройдите по контроллерам и найдите повторяющиеся цепочки where — это кандидаты в локальные скоупы. Условия, которые обязаны быть в каждом запросе модели, выносите в глобальный скоуп и сразу проверьте все места, где вы обходите Eloquent сырыми запросами: там скоуп не сработает. Включите логирование SQL на стенде и посмотрите реальные запросы после рефакторинга — особенно там, где есть orWhere. Раздел Query Scopes в документации Laravel держите под рукой: синтаксис регистрации глобальных скоупов между 10.x и 11.x отличается.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: Laravel — Eloquent: Query Scopes
Частые вопросы
Что если у меня Laravel 10, а не 11 — атрибут ScopedBy сработает?
Скоуп вернул не то — лишние записи, хотя условие вроде верное. В чём дело?
Можно ли передать аргумент в глобальный скоуп?
Как временно отключить глобальный скоуп для одного запроса?
Скоупы замедляют запросы?
Работают ли скоупы с отношениями, например $user->posts()->published()?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.