Страница категории открывается две секунды, хотя в базе тысяча записей и простой индекс. Включите лог запросов — и увидите сто одинаковых select * from authors where id = ?. Это классическая проблема N+1: один запрос за списком и по одному запросу на каждую строку.
Что такое N+1
Eloquent ленивый по умолчанию. Пока вы не обратились к связи, он про неё не знает. Код выглядит безобидно:
$posts = Post::latest()->take(50)->get();
foreach ($posts as $post) {
echo $post->author->name;
}
Первый запрос выбирает 50 статей. Дальше на каждой итерации выполняется ещё один запрос за автором. Итого 51 запрос вместо двух. На локальной базе это незаметно, на реальной — десятки миллисекунд, умноженные на число строк.
Как заметить проблему
Самый быстрый способ — посчитать запросы в рамках одного запроса HTTP:
\DB::listen(function ($query) {
\Log::debug($query->sql, $query->bindings);
});
Вставьте это временно в сервис-провайдер или прямо в маршрут. Если в логе десятки почти одинаковых строк с разными id — это N+1. Для повседневной работы удобнее Laravel Debugbar или Telescope, но на хостинге они обычно отключены, а лог доступен всегда.
Второй сигнал — время ответа растёт линейно от числа записей в списке, хотя пагинация не менялась.
with() и load()
Жадная загрузка говорит Eloquent: выбери связи заранее, одним дополнительным запросом.
$posts = Post::with('author')->latest()->take(50)->get();
Теперь запросов два: статьи и авторы через where id in (...). Если данные нужны не сразу, а после условия, используйте load():
$posts = Post::latest()->take(50)->get();
if ($showAuthors) {
$posts->load('author');
}
loadMissing() делает то же самое, но пропускает уже загруженные связи. Это удобно в шаблонах и презентерах, где неизвестно, что успели подгрузить раньше.
Вложенные связи задаются точкой: with('author.company'). Условие на связь — замыканием:
Post::with(['comments' => fn ($q) => $q->latest()->limit(3)])->get();
Осторожно: limit внутри жадной загрузки в старых версиях Laravel работал не так, как ожидается. Если нужны «три последних комментария к каждой статье», надёжнее отдельная связь или HasManyThrough с продуманным запросом.
withCount вместо count в цикле
Ещё один вариант N+1 — счётчики. Код $post->comments()->count() в цикле даёт по запросу на строку. Eloquent умеет посчитать всё одним запросом:
$posts = Post::withCount('comments')->get();
foreach ($posts as $post) {
echo $post->comments_count;
}
Для условий используйте withCount(['comments as approved_count' => fn ($q) => $q->where('approved', true)]). Это дешевле, чем загружать все комментарии ради числа.
Запрет ленивой загрузки
В Laravel есть страховка: если связь не загружена заранее, выбрасывается исключение. Включают её обычно только вне production:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(! app()->isProduction());
}
Такой режим быстро показывает все проблемные места на тестах и в разработке. В production его лучше не включать: исключение на живой странице хуже, чем лишний запрос.
Когда жадная загрузка вредит
with() — не бесплатная операция. Если связь нужна только для одной карточки из ста, вы загрузите данные впустую. Ещё хуже — цепочка из нескольких with на большой таблице: MySQL может выбрать неудачный план.
Проверяйте не только число запросов, но и их стоимость через EXPLAIN. Иногда правильнее сделать один join с выборкой нужных колонок, чем три запроса с полными моделями. И не забывайте про select(): по умолчанию Eloquent тянет все поля.
Для очень больших выгрузок используйте chunk() или cursor(), а не get(). Иначе память закончится раньше, чем вы дойдёте до оптимизации.
Если тяжёлая выборка не нужна в ответе сразу, вынесите её в очередь: об этом — очереди Laravel без Redis.
Проверка после правок
Сравните число запросов до и после. Хороший признак — количество перестало зависеть от размера страницы. Если было 101 и стало 3, дальше можно смотреть на индексы и кэш.
Чтобы регрессия не вернулась, закрепите ожидаемое число запросов тестом — с чего начать, подскажет статья AI для тестов.
И не оптимизируйте вслепую: сначала измерение, потом with. Преждевременная жадная загрузка усложняет код и не даёт выигрыша там, где связи не используются.
Коротко
- N+1 — это один запрос за списком плюс запрос на каждую строку;
with()иload()убирают лишние обращения к базе;withCount()заменяетcount()в цикле;preventLazyLoading()помогает найти проблему в разработке;- жадная загрузка тоже стоит денег — проверяйте планы запросов.