Все самое полезное для пхпшника в одном канале.
По рекламе: @tproger_sales_bot
Учиться у нас: clc.to/M561SQ
Для обратной связи: @proglibrary_feeedback_bot
РКН: https://gosuslugi.ru/snet/67a5d13cd6fa92100ee6f68b
Post #6595
1.54K
ℹ️ Eloquent Query Classes: дать важному запросу имя
Eloquent отличный слой работы с БД, и большую часть времени where()->with()->paginate() прямо в контроллере или джобе — это нормально. Лишняя абстракция на каждый чих только мешает.
Но проект растёт, и часть запросов перестаёт быть деталью реализации. Они становятся частью бизнес-логики.
Один и тот же фильтр всплывает в дашборде, экспорте, отчёте, фоновой джобе и API. Дублируешь — копии разъезжаются. Прячешь в модель, она пухнет от скоупов и статиков.
В этот момент запросу пора дать имя.
🔹 Что это
Query Class — это Action-паттерн, применённый к запросам. Один публичный вход — handle(). Принимает параметры, строит/выполняет запрос, возвращает результат: коллекцию, пагинатор, агрегат, число затронутых строк или Builder, если вызывающему нужно продолжить композицию.
Контроллер больше не знает определения «заказов, требующих внимания». Это определение теперь называется PendingOrdersQuery.
🔹 Это НЕ репозиторий
Репозиторий обычно оборачивает каждый метод модели в generic-CRUD и притворяется, что БД нет. Query Class делает обратное: не прячет Eloquent, а централизует один конкретный важный запрос. Репозиторий = источник данных. Query Class = одна операция против БД с чёткой целью.
🔹 А чем плохи local scopes?
Ничем. Для переиспользуемого ограничения, которое естественно принадлежит модели, скоуп — первый инструмент. Query Class уместен, когда запрос перерос простое ограничение: координирует несколько скоупов, тащит кучу опциональных фильтров, рулит eager loading под конкретный экран, возвращает агрегаты, используется из нескольких точек или требует отдельных тестов.
И они отлично работают вместе: модель владеет доменным ограничением (needingAttention()), Query Class — сценарием использования БД.
🔹 handle() возвращает результат или Builder?
Единый результат — выполняй внутри (->count(), ->paginate()). Один запрос нужен разным потребителям с разными типами результата — верни Builder:
Только не превращай класс в свалку публичных методов — это снова тот самый generic-репозиторий. Хорошие Query Classes скучные и сфокусированные.
🔹 Писать тоже умеют
Если ценность именно в самой операции с БД, запрос на запись тоже подходит:
Но если запись часть бизнес-воркфлоу (сайд-эффекты, диспатч джоб, смена стейта), её место в Action, а не здесь.
🔹 Когда использовать
Когда у операции есть бизнес-смысл: CustomersEligibleForDiscountQuery, InvoicesReadyToBeChargedQuery. Когда запрос повторяется, тяжёлый по фильтрам/eager loading, живёт в отчётах и фоне, должен тестироваться отдельно, а модель уже захлёбывается скоупами. Бонус — онбординг: новичок найдёт SearchOrdersQuery быстрее, чем цепочку в недрах контроллера.
🔹 Когда НЕ использовать
Article::findOrFail($id) не нуждается в FindArticleByIdQuery. Если имя класса просто дублирует метод Eloquent, если запрос в одном месте и прост, если скоуп уже всё выражает — не плоди классы. Начинай с чистого Eloquent. Выделяй Query Class, когда запрос это заслужил.
🔹 Тесты
Тестируем не Eloquent (его уже протестировали в Laravel), а наши правила: какие записи попадают, какие отсекаются, в каком порядке. Один тест на дефолтное поведение + точечные на важные фильтры и рискованные края. Комбинаторика всех фильтров не нужна.
🔹 Пять правил на каждый день
— Имя по бизнес-вопросу (PendingOrdersQuery), а не по операции (GetOrdersQuery)
— С записями избирательно: бизнес-правила → Action
— Без абстрактных базовых классов, пока нет реального дублирования
— Один публичный метод handle(), остальное private
— Не прячь Eloquent ради пряток. Возвращать Builder нормально
Ценность паттерна не в количестве классов, а в том, что код становится легче понимать, менять и доверять ему со временем.
Eloquent отличный слой работы с БД, и большую часть времени where()->with()->paginate() прямо в контроллере или джобе — это нормально. Лишняя абстракция на каждый чих только мешает.
Но проект растёт, и часть запросов перестаёт быть деталью реализации. Они становятся частью бизнес-логики.
Один и тот же фильтр всплывает в дашборде, экспорте, отчёте, фоновой джобе и API. Дублируешь — копии разъезжаются. Прячешь в модель, она пухнет от скоупов и статиков.
В этот момент запросу пора дать имя.
🔹 Что это
Query Class — это Action-паттерн, применённый к запросам. Один публичный вход — handle(). Принимает параметры, строит/выполняет запрос, возвращает результат: коллекцию, пагинатор, агрегат, число затронутых строк или Builder, если вызывающему нужно продолжить композицию.
final readonly class PendingOrdersQuery
{
public function handle(?int $merchantId = null, int $perPage = 50): LengthAwarePaginator
{
return Order::query()
->with(['customer', 'payment'])
->whereIn('status', [OrderStatus::Pending, OrderStatus::PaymentFailed])
->where('created_at', '<=', now()->subMinutes(15))
->when($merchantId !== null,
fn (Builder $q) => $q->where('merchant_id', $merchantId))
->latest()
->paginate($perPage);
}
}
Контроллер больше не знает определения «заказов, требующих внимания». Это определение теперь называется PendingOrdersQuery.
🔹 Это НЕ репозиторий
Репозиторий обычно оборачивает каждый метод модели в generic-CRUD и притворяется, что БД нет. Query Class делает обратное: не прячет Eloquent, а централизует один конкретный важный запрос. Репозиторий = источник данных. Query Class = одна операция против БД с чёткой целью.
🔹 А чем плохи local scopes?
Ничем. Для переиспользуемого ограничения, которое естественно принадлежит модели, скоуп — первый инструмент. Query Class уместен, когда запрос перерос простое ограничение: координирует несколько скоупов, тащит кучу опциональных фильтров, рулит eager loading под конкретный экран, возвращает агрегаты, используется из нескольких точек или требует отдельных тестов.
И они отлично работают вместе: модель владеет доменным ограничением (needingAttention()), Query Class — сценарием использования БД.
🔹 handle() возвращает результат или Builder?
Единый результат — выполняй внутри (->count(), ->paginate()). Один запрос нужен разным потребителям с разными типами результата — верни Builder:
$articles = new PublishedArticlesQuery()->handle()->paginate(12);
$urls = new PublishedArticlesQuery()->handle()->get();
Только не превращай класс в свалку публичных методов — это снова тот самый generic-репозиторий. Хорошие Query Classes скучные и сфокусированные.
🔹 Писать тоже умеют
Если ценность именно в самой операции с БД, запрос на запись тоже подходит:
final readonly class ExpireAbandonedOrdersQuery
{
public function handle(CarbonImmutable $expiredBefore): int
{
return Order::query()
->where('status', OrderStatus::Pending)
->where('created_at', '<=', $expiredBefore)
->update(['status' => OrderStatus::Expired, 'expired_at' => now()]);
}
}
Но если запись часть бизнес-воркфлоу (сайд-эффекты, диспатч джоб, смена стейта), её место в Action, а не здесь.
🔹 Когда использовать
Когда у операции есть бизнес-смысл: CustomersEligibleForDiscountQuery, InvoicesReadyToBeChargedQuery. Когда запрос повторяется, тяжёлый по фильтрам/eager loading, живёт в отчётах и фоне, должен тестироваться отдельно, а модель уже захлёбывается скоупами. Бонус — онбординг: новичок найдёт SearchOrdersQuery быстрее, чем цепочку в недрах контроллера.
🔹 Когда НЕ использовать
Article::findOrFail($id) не нуждается в FindArticleByIdQuery. Если имя класса просто дублирует метод Eloquent, если запрос в одном месте и прост, если скоуп уже всё выражает — не плоди классы. Начинай с чистого Eloquent. Выделяй Query Class, когда запрос это заслужил.
🔹 Тесты
Тестируем не Eloquent (его уже протестировали в Laravel), а наши правила: какие записи попадают, какие отсекаются, в каком порядке. Один тест на дефолтное поведение + точечные на важные фильтры и рискованные края. Комбинаторика всех фильтров не нужна.
🔹 Пять правил на каждый день
— Имя по бизнес-вопросу (PendingOrdersQuery), а не по операции (GetOrdersQuery)
— С записями избирательно: бизнес-правила → Action
— Без абстрактных базовых классов, пока нет реального дублирования
— Один публичный метод handle(), остальное private
— Не прячь Eloquent ради пряток. Возвращать Builder нормально
Ценность паттерна не в количестве классов, а в том, что код становится легче понимать, менять и доверять ему со временем.
- 👍 11
- ❤ 3
- 🔥 2
- 😁 1







