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 сам.
Чекліст
Model::preventLazyLoading(! app()->isProduction())уAppServiceProvider.- У продакшені
handleLazyLoadingViolationUsingіз логом і метрика кількості запитів. with()на запиті, який формує колекцію, а не всередині циклу.withCountіwithSumзамість завантаження звʼязків заради агрегатів.- Feature-тест на кожну сторінку зі списком: якщо кількість запитів залежить від кількості елементів, це N+1.
Той самий матеріал у форматі картки для співбесіди, з типовими помилками й додатковими питаннями: Як діагностувати й виправити проблему N+1.