Eloquent-скоупы и переиспользование запросов: сравниваем варианты
Один и тот же фильтр «только активные» расползается по десяти контроллерам. Разбираем скоупы, кастомные билдеры, DTO-фильтры и репозитории в Laravel 11 — с ценой каждого подхода.

Условие where('status', 'active')->whereNull('deleted_at')->where('published_at', '<=', now()) живёт в контроллере списка, в контроллере поиска, в фиде для карты сайта и ещё в трёх местах. Кто-то забыл про published_at в одном из них — и на витрине появился черновик. Задача не в том, чтобы написать фильтр, а в том, чтобы он был ровно один и переиспользовался без копипасты. В Laravel для этого есть минимум четыре механизма, и они не взаимозаменяемы. Разбираем на Laravel 11.x, Eloquent из коробки.
Что именно мы переиспользуем
Прежде чем выбирать инструмент, разделите два разных желания. Первое — переиспользовать кусок условия: «активные», «за последний месяц», «принадлежащие пользователю». Второе — переиспользовать целый сценарий сборки запроса: список товаров для витрины с сортировкой, пагинацией и eager loading. Для первого подходят скоупы, для второго — билдеры и репозитории. Смешивать их в одном механизме — как раз то, из-за чего запросы превращаются в нечитаемую цепочку.
Локальный скоуп — самый частый выбор
Локальный скоуп — это метод модели с префиксом scope, который принимает Builder и модифицирует его. Вызывается без префикса.
// app/Models/Post.php
class Post extends Model
{
// Только опубликованные и не удалённые
public function scopePublished(Builder $query): Builder
{
return $query->where('status', 'active')
->whereNull('deleted_at')
->where('published_at', '<=', now());
}
// Скоуп с аргументом
public function scopeOfAuthor(Builder $query, int $authorId): Builder
{
return $query->where('author_id', $authorId);
}
}
// Использование
$posts = Post::published()->ofAuthor($userId)->latest()->paginate(20);
Плюс очевиден: условие описано один раз, вызывается декларативно, читается как предложение. Цена — скоупы привязаны к одной модели. Если тот же фильтр нужен для трёх разных моделей, копипаста возвращается, только теперь в виде трёх похожих методов.
Глобальный скоуп — тихая магия
Глобальный скоуп применяется ко всем запросам модели автоматически, пока вы его явно не отключите. Классический пример — soft delete: Laravel сам добавляет whereNull('deleted_at') именно через глобальный скоуп.
// app/Models/Scopes/PublishedScope.php
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
class PublishedScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
$builder->where('status', 'active')
->where('published_at', '<=', now());
}
}
// В модели (Laravel 11: атрибут ScopedBy или метод booted)
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
#[ScopedBy([PublishedScope::class])]
class Post extends Model
{
//
}
// Отключить для конкретного запроса
$all = Post::withoutGlobalScope(PublishedScope::class)->get();
Атрибут #[ScopedBy] появился в Laravel 11; в 10.x и раньше глобальный скоуп регистрировали в методе booted() через static::addGlobalScope(). Механизм сам по себе не новый.
Глобальный скоуп удобен ровно до того момента, пока кто-то не потратит два часа на вопрос «почему запрос не возвращает эту запись, хотя она есть в базе». Невидимое условие — это удобство для чтения кода и ловушка для отладки. Применяйте его только к условиям, которые верны почти всегда.
Кастомный Builder — когда скоупов становится много
Когда на модели десяток скоупов и хочется автодополнение в IDE и типизацию, выносят собственный класс билдера. Модель переопределяет newEloquentBuilder и возвращает его.
// app/Models/Builders/PostBuilder.php
use Illuminate\Database\Eloquent\Builder;
class PostBuilder extends Builder
{
public function published(): self
{
return $this->where('status', 'active')
->where('published_at', '<=', now());
}
public function popular(int $minViews = 1000): self
{
return $this->where('views', '>=', $minViews);
}
}
// В модели
class Post extends Model
{
public function newEloquentBuilder($query): PostBuilder
{
return new PostBuilder($query);
}
}
// Использование — то же, что со скоупами, но с типами и автодополнением
$posts = Post::query()->published()->popular(5000)->get();
По сравнению с локальными скоупами кастомный билдер даёт нормальную типизацию возвращаемого значения (метод возвращает PostBuilder, а не «магический» результат __call) и не заставляет плодить scope-методы. Цена — больше кода на инфраструктуру и та же привязка к одной модели.
Класс-фильтр под параметры запроса
Отдельная боль — фильтрация по параметрам из строки запроса: ?status=active&author=5&sort=-created_at. Складывать это в контроллер через цепочку when() быстро превращается в лапшу. Выносят отдельный класс, который принимает массив параметров и модифицирует билдер.
// app/Filters/PostFilter.php
use Illuminate\Database\Eloquent\Builder;
class PostFilter
{
public function __construct(private array $params) {}
public function apply(Builder $query): Builder
{
return $query
->when($this->params['author'] ?? null,
fn ($q, $author) => $q->where('author_id', $author))
->when($this->params['status'] ?? null,
fn ($q, $status) => $q->where('status', $status));
}
}
// В контроллере
$filter = new PostFilter($request->only(['author', 'status']));
$posts = $filter->apply(Post::published())->paginate(20);
Это не замена скоупам, а слой над ними: базовые условия остаются в скоупе published(), а изменчивая пользовательская фильтрация — в классе-фильтре. Каждый вопрос — на своём месте.
Сравнение подходов
| Подход | Что переиспользуем | Типизация / IDE | Главный риск |
|---|---|---|---|
| Локальный скоуп | Кусок условия на одной модели | Слабая (магия __call) | Копипаста между моделями |
| Глобальный скоуп | Условие, верное почти всегда | Не нужна | Невидимое условие ломает отладку |
| Кастомный Builder | Набор условий одной модели | Полная | Больше инфраструктурного кода |
| Класс-фильтр | Разбор параметров запроса | Средняя | Не заменяет базовые скоупы |
Когда что выбирать
- Условие живёт на одной модели и его 2–5 штук — локальные скоупы. Не усложняйте.
- Условие должно применяться по умолчанию всегда (мультитенантность, «только свои», soft delete) — глобальный скоуп, но обязательно с явным способом его отключить и с комментарием, почему он есть.
- Скоупов больше десятка, важна типизация и автодополнение — кастомный
Builder. - Фильтр строится из пользовательского ввода (GET-параметры, форма поиска) — отдельный класс-фильтр поверх скоупов.
- Логика запроса нужна вне контекста конкретной модели или вы хотите скрыть Eloquent от бизнес-слоя — тогда репозиторий. Но заранее: репозиторий поверх Eloquent часто дублирует то, что Eloquent уже умеет, и лишает вас скоупов, eager loading и chunk из коробки. Это отдельная большая тема, здесь её не разворачиваем.
Что ломается
Три ошибки, которые встречаются чаще остальных.
Скоуп с orWhere без группировки. Если внутри скоупа есть orWhere, а потом вы дописываете к запросу ещё условия, приоритет операторов даст неожиданный результат: WHERE a AND b OR c — не то, что вы имели в виду. Оборачивайте or-условия в замыкание: $query->where(fn ($q) => $q->where(...)->orWhere(...)).
Забыли отключить глобальный скоуп в админке. Классика: PublishedScope прячет черновики от посетителей — и заодно от редактора в админ-панели, который не понимает, куда делись его неопубликованные записи. В админских запросах не забывайте withoutGlobalScope (или заведите отдельную модель без скоупа).
Скоуп ничего не возвращает. Если метод скоупа не вернёт $query, а вы попробуете продолжить цепочку — получите ошибку вызова метода на null. Скоуп либо возвращает билдер, либо ничего не возвращает, но тогда его нельзя ставить в середину цепочки как источник значения.
Что делать дальше
Начните с самого простого: соберите повторяющиеся where по проекту и вынесите каждый в локальный скоуп с говорящим именем. Дальше смотрите на реальную боль. Если IDE не подсказывает методы и вы устали от «magic method» в статическом анализаторе — переходите на кастомный Builder. Если тянет прятать Eloquent за репозиторием, сначала честно спросите, какую проблему это решает, кроме «так принято». Отдельно посмотрите на пакет spatie/laravel-query-builder, если фильтрация из query-параметров у вас сложнее пары полей — он берёт на себя разбор ?filter[], ?sort= и ?include= по спецификации.
Репетитор 1—11 класс и ВУЗ: Математика и физика методом наведения на мысль, помощь с ДЗ.
Спросить за 8 ₽Источники: Laravel 11 — Eloquent: Query Scopes, spatie/laravel-query-builder
Частые вопросы
А если у меня Laravel 10, атрибут ScopedBy не работает?
Скоуп не срабатывает — запрос возвращает всё подряд. В чём дело?
Скоупы замедляют запросы?
Можно ли использовать скоупы вместе с whereHas и eager loading?
Глобальный скоуп или переопределение метода в базовой модели — что чище для мультитенантности?
Стоит ли сразу выносить всё в репозитории вместо скоупов?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.