Страница категории открывается две секунды, хотя в базе тысяча записей и простой индекс. Включите лог запросов — и увидите сто одинаковых 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() помогает найти проблему в разработке;
  • жадная загрузка тоже стоит денег — проверяйте планы запросов.