Оптимизация Eloquent в Laravel: рабочие приёмы против N+1
Eloquent удобен, пока страница не начинает открываться три секунды и не выполняет 400 запросов вместо трёх. Разбираем приёмы, которые убирают лишние запросы и лишнюю память.

Список из 50 заказов открывается за 2,8 секунды, а в логе запросов — 213 строк. Каждая карточка тянет клиента, статус и позиции отдельными запросами. Это классический N+1, и в Laravel он вылезает почти в каждом проекте, который начинали писать быстро. Ниже — приёмы, которые убирают лишние запросы, лишние выборки колонок и лишнюю память при работе с большими выборками. Речь про Laravel 10.x и 11.x, поведение методов в них совпадает.
Проблема, с которой всё начинается
Очевидное решение — просто перебрать модели и обратиться к связи:
// Laravel 11.x
$orders = Order::limit(50)->get();
foreach ($orders as $order) {
// на каждой итерации — отдельный SELECT к таблице users
echo $order->user->name;
}
Один запрос за списком заказов и по одному за клиентом на каждую строку. 50 заказов — 51 запрос. Добавьте вывод позиций — счёт пойдёт на сотни. Проблема не в количестве данных, а в количестве обращений к базе: сетевая задержка на каждый запрос складывается.
Приём 1. Eager loading через with()
Базовый инструмент против N+1 — загрузить связи заранее одним-двумя запросами:
// два запроса всего: заказы + все нужные клиенты одним IN (...)
$orders = Order::with('user')->limit(50)->get();
foreach ($orders as $order) {
echo $order->user->name; // из памяти, без запроса
}
Вложенные и множественные связи указываются через точку и массив:
$orders = Order::with(['user', 'items.product'])->get();
Здесь Laravel выполнит четыре запроса независимо от числа заказов: заказы, пользователи, позиции, товары. Цена приёма — память: все связанные записи держатся в объектах до конца жизни коллекции.
Не тяните лишние колонки
Если из связи нужны два поля, не грузите всю строку. У with() есть форма с выбором колонок, но в ней обязательно указывать ключ связи, иначе Laravel не сможет сопоставить записи:
// id обязателен — по нему идёт сопоставление
$orders = Order::with(['user:id,name,email'])->get();
Приём 2. Ленивая догрузка через load()
Когда коллекция уже получена, а связь понадобилась не всегда, используйте load() — он догружает связь тем же способом, что и with(), одним запросом на всю коллекцию:
$orders = Order::limit(50)->get();
if ($needClients) {
$orders->load('user'); // один SELECT ... WHERE id IN (...)
}
Разница с with() — момент загрузки. with() решает заранее, load() — по условию после выборки. Оба избегают N+1.
Приём 3. Считайте связи через withCount
Частая ошибка — загрузить связь целиком, чтобы вызвать count() в шаблоне. Если нужно только число, грузить объекты незачем:
// плохо: тянем все позиции ради count()
$orders = Order::with('items')->get();
// в шаблоне: $order->items->count()
// хорошо: подзапрос считает на стороне БД
$orders = Order::withCount('items')->get();
// в шаблоне: $order->items_count
withCount добавляет коррелированный подзапрос и колонку items_count. Данные позиций в память не попадают вовсе — на больших таблицах разница в памяти кратная.
Правило, которое экономит больше всего: не грузите объекты ради агрегата. Нужно число — считайте в базе через withCount, withSum, withAvg. Нужно проверить наличие — используйте whereHas, а не загрузку связи с последующей проверкой в PHP.
Приём 4. select и chunk на больших выборках
Когда строк не 50, а 500 000, проблема смещается с количества запросов на память. get() тянет всё в память разом. Для обработки больших наборов есть два подхода:
// chunkById обрабатывает пачками, память не растёт
Order::where('status', 'archived')
->select(['id', 'total', 'created_at'])
->chunkById(1000, function ($orders) {
foreach ($orders as $order) {
// обработка пачки
}
});
chunkById надёжнее обычного chunk, когда внутри цикла вы меняете строки: он листает по первичному ключу, а не по offset, и не пропускает записи из-за сдвига страниц.
Если нужен только перебор без изменений — подойдёт lazy() (доступен с Laravel 8.x), который отдаёт LazyCollection и подгружает записи по мере итерации.
Не забывайте про select колонок
Указание select с нужными полями снимает нагрузку и по трафику до базы, и по памяти. Особенно ощутимо на таблицах с длинными text/json-колонками, которые в конкретной задаче не нужны.
Сравнение приёмов
| Приём | Убирает | Цена | Когда применять |
|---|---|---|---|
with() | N+1 при известной наперёд связи | Память под все связанные объекты | Связь нужна почти всегда |
load() | N+1 при условной загрузке | Та же память, но по условию | Коллекция уже получена |
withCount() | Загрузку объектов ради count | Подзапрос на строку | Нужно только число |
chunkById() | Разовую загрузку всего в память | Больше запросов, но постоянная память | Десятки тысяч строк и больше |
select() | Лишние колонки | Нужно помнить про ключи связей | Почти всегда |
Когда что выбирать
- Связь нужна во всех строках —
with()на этапе запроса. - Связь нужна по условию после выборки —
load(). - Нужно число или сумма связанных записей —
withCount(),withSum(). - Нужно отфильтровать по наличию связи —
whereHas(), а не загрузка и фильтрация в PHP. - Обрабатываете тысячи строк в фоне —
chunkById()илиlazy()вместе с явнымselect.
Что ломается
select без ключей связи. Если в with(['user:name']) забыть id, связь придёт пустой — Laravel нечем сопоставить user_id заказа с пользователем. Для belongsTo нужен первичный ключ связанной модели, для hasMany — внешний ключ.
withCount и with на одной связи. Их можно сочетать, но помните: это два разных обращения к базе — подзапрос для счётчика и отдельный запрос для объектов. Если вам и объекты, и число, иногда дешевле загрузить объекты через with и взять count() в памяти.
chunk с изменением строк. Обычный chunk с where, по которому вы обновляете записи внутри цикла, пропускает строки: обновлённые перестают попадать в выборку, страницы сдвигаются. Для таких случаев только chunkById.
Скрытый N+1 в аксессорах. Если аксессор модели обращается к связи, обычный обход коллекции в шаблоне снова породит N+1, даже если сам список загружен через with. Аксессор надо покрывать той же eager-загрузкой.
Как это увидеть в проекте
Считать запросы на глаз бессмысленно. Поставьте в dev-окружение Laravel Debugbar или Telescope — они показывают число запросов на страницу и подсвечивают дубли. Ориентир простой: если на карточку списка приходится больше пары запросов сверх постоянного числа, у вас N+1.
Для автоматической защиты в Laravel 9.x и новее есть Model::preventLazyLoading() — в дев-окружении она бросает исключение при обращении к незагруженной связи, не давая N+1 дожить до прода. Включается в boot() сервис-провайдера. Точное имя и поведение сверяйте с разделом Eloquent в документации вашей версии.
Что дальше
После того как убрали N+1, смотрите на сами запросы: EXPLAIN на медленных выборках, индексы под внешние ключи и колонки в where. Оптимизация запросов и индексов — отдельная тема, здесь мы работали только с числом обращений и объёмом выборки на уровне Eloquent. Следующий показатель, который стоит мерить в проде, — время ответа и число запросов на ключевых страницах, а не абстрактная нагрузка на базу.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Laravel — Eloquent: Relationships (Eager Loading), Laravel — Eloquent: Getting Started (Chunking, Lazy)
Частые вопросы
А если у меня Laravel 8 или 9, приёмы сработают?
Я добавил with(), но запросов всё равно много. Почему?
with() или load() — что лучше по производительности?
Стоит ли включать preventLazyLoading на проде?
chunk и chunkById — можно использовать любой?
withCount создаёт подзапрос на каждую строку — это не медленнее, чем загрузить связь?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.