<? phpukraine СТАТТІ
Пошук по платформі
LARAVEL 2 вересня 2026 · 4 хв читання

N+1 в Eloquent: як знайти, виправити і більше не допустити

Кожен запит швидкий, а сторінка повільна. Розбираємо механіку N+1 у Laravel, три способи його зловити до продакшену і випадки, коли eager loading робить гірше.

РP
Редакція phpukraine
Редакція платформи

N+1 — найпоширеніша проблема продуктивності в Laravel-проєктах і водночас найпідступніша: профайлер повільних запитів її не бачить. Кожен окремий запит виконується за мілісекунду. Проблема в тому, що їх пʼятсот. Ця стаття про те, як N+1 виникає, як його зловити ще в тестах і чому сліпий with() на всьому підряд не є відповіддю.

Механіка: звідки беруться N запитів

Eloquent завантажує звʼязки ліниво. Коли ви пишете $post->author, а звʼязок ще не завантажений, модель робить окремий запит до бази. У циклі по колекції це перетворюється на запит на кожну ітерацію:

// 1 запит на пости + 50 запитів на авторів = 51 запит
foreach (Post::latest()->limit(50)->get() as $post) {
    echo $post->author->name;
}

Те саме відбувається в Blade. @foreach ($posts as $post) {{ $post->author->name }} @endforeach виглядає невинно, але кожна ітерація йде в базу. А якщо у шаблоні картки поста ще й $post->comments->count(), запитів стає 101.

Ключова ознака N+1 — кількість запитів росте разом із кількістю елементів на сторінці. Сторінка з 20 елементами робить 41 запит, з 50 елементами 101. Тому сторінка в розробці на трьох записах виглядає швидкою, а в продакшені на пагінації по 50 гальмує.

Виправлення: eager loading і його форми

Виправлення завжди одне: завантажити звʼязок заздалегідь. Eloquent робить один додатковий запит із WHERE id IN (...) і розкладає результат по моделях.

// 2 запити замість 51
$posts = Post::with('author')->latest()->limit(50)->get();

// Кілька звʼязків і вкладені
$posts = Post::with(['author', 'comments.user'])->get();

// Звʼязок для вже отриманої колекції, наприклад із кешу
$posts->load('author');
$posts->loadMissing('author'); // пропускає вже завантажені

Коли потрібен лише агрегат, самі звʼязані рядки тягнути не треба:

// comments_count без завантаження коментарів
$posts = Post::withCount('comments')->get();

// Сума й максимум теж є
$orders = Order::withSum('items', 'total')->withMax('items', 'created_at')->get();

Обмежений eager loading через замикання дозволяє взяти лише частину звʼязку:

$posts = Post::with(['comments' => fn ($q) => $q->latest()->limit(3)])->get();

Тут є пастка: limit усередині with застосовується до всього обʼєднаного запиту, а не до кожного поста. Laravel 11+ виправляє це для більшості баз через віконні функції, але на старіших версіях ви отримаєте три коментарі загалом, а не три на пост.

Як зловити N+1 до продакшену

Найважливіша частина. Виправити один N+1 легко; складніше зробити так, щоб нові не зʼявлялись.

preventLazyLoading у розробці й тестах

З Laravel 8.43 є вимикач лінивого завантаження. Увімкніть його всюди, крім продакшену:

// AppServiceProvider::boot()
Model::preventLazyLoading(! $this->app->isProduction());

Тепер будь-який лінивий доступ до звʼязку кидає LazyLoadingViolationException. N+1 падає ще у feature-тестах, а не після деплою. Це найдешевший захист із усіх можливих.

Логування у продакшені замість винятку

У продакшені кидати виняток небезпечно: краще дізнатись про проблему з логів, ніж від користувача.

Model::preventLazyLoading();

Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
    Log::warning('Lazy loading', ['model' => $model::class, 'relation' => $relation, 'url' => request()->fullUrl()]);
});

Так порушення потрапляють у лог або Sentry, а сторінка працює.

Лічильник запитів на HTTP-запит

Незалежно від Eloquent корисно знати, скільки запитів робить кожна сторінка:

// Middleware або AppServiceProvider
DB::listen(function (QueryExecuted $query) {
    app()->instance('queries.count', app('queries.count', 0) + 1);
});

// Наприкінці запиту: алерт, якщо більше 50

Debugbar і Telescope показують те саме локально, але саме метрика в продакшені виявляє N+1 на тих сторінках, куди розробники не заходять.

Коли eager loading шкодить

with() не безкоштовний, і ставити його на все підряд теж помилка.

  • Великі звʼязки заради лічильника. with('comments') на тисячу постів витягне сотні тисяч рядків у памʼять, щоб порахувати count(). Використовуйте withCount.
  • Глобальний $with у моделі. Він завантажує звʼязок навіть там, де він не потрібен, наприклад в API-ресурсі, який віддає лише id і назву. Краще явний with() у кожному запиті.
  • Глибокі вкладення на великих колекціях. with('comments.user.profile') на 500 постів це чотири запити, але десятки тисяч моделей у памʼяті. Тут доречна окрема пагінація або підзапити через addSelect.
  • API-ресурси. JsonResource часто звертається до звʼязків через whenLoaded. Правило: контролер завантажує звʼязки, ресурс лише читає їх і ніколи не ініціює lazy loading сам.

Чекліст

  1. Model::preventLazyLoading(! app()->isProduction()) у AppServiceProvider.
  2. У продакшені handleLazyLoadingViolationUsing із логом і метрика кількості запитів.
  3. with() на запиті, який формує колекцію, а не всередині циклу.
  4. withCount і withSum замість завантаження звʼязків заради агрегатів.
  5. Feature-тест на кожну сторінку зі списком: якщо кількість запитів залежить від кількості елементів, це N+1.

Той самий матеріал у форматі картки для співбесіди, з типовими помилками й додатковими питаннями: Як діагностувати й виправити проблему N+1.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 3 вересня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті