N+1 — це класична проблема Eloquent, яка непомітна на малих даних і гарантовано “вистрілить”, коли сторінка стане реальною робочою. Суть проста: ви робите один запит за списком (це “1”), а потім у циклі робите ще N запитів по кожному елементу (це “+N”). Наприклад, витягли 50 новин, а потім у Blade для кожної звернулися до $news->author->name і $news->section->title без eager loading. У результаті: 1 запит на новини + 50 на авторів + 50 на секції. На локалці це може “пролетіти”. У проді — сторінка стає повільною, база перегрівається, а користувачі чекають.
Чому N+1 небезпечний саме для адмінки. Бо адмінка часто показує багато сутностей на сторінці і тягне багато зв’язків: автор, ресурс, секція, статуси, медіа, лічильники, activity. Якщо ви не тримаєте це під контролем, кожен новий стовпчик у таблиці стає ще десятком запитів. А далі приходять фільтри, join, сортування — і ви отримуєте повний хаос.
Де N+1 найчастіше виникає. Перше місце — Blade, коли ви в таблиці робите доступ до зв’язку: $row->user->name, $row->resource->title, $row->topic->slug. Друге — коли ви рахуєте щось у циклі: $row->comments()->count() або $row->media()->exists() — це ще гірше, бо це не просто lazy load зв’язку, а окремі “агрегатні” запити на кожен рядок. Третє — коли ви викликаєте accessor, який всередині лазить у зв’язки або робить запит. Це підступно: зовні ви просто вивели $row->pretty_status, а всередині accessor зробив SQL.
Як правильно прибирати N+1. Основний інструмент — eager loading через with(). Ви заздалегідь кажете Eloquent: “мені потрібні автори і секції для всього списку”. Тоді Laravel робить 1 запит на список + 1 запит на всіх авторів (WHERE IN ids) + 1 запит на всіх секцій. Це не магія, це перехід від “десятків дрібних запитів” до “кількох великих”. Другий інструмент — withCount() для лічильників, коли вам треба “скільки коментарів/медіа/лайків”. Третій — loadMissing() у випадках, коли ви вже маєте колекцію і хочете дозавантажити зв’язки один раз (але краще робити все в query одразу).
Дуже важливе правило: eager loading має бути точним. Не тягніть “все підряд”, бо ви можете замінити N+1 на проблему пам’яті та часу на передачу даних. Для списку вам зазвичай потрібні мінімальні поля зв’язку: id + name/title. Тому правильний підхід — обмежувати колонки. Це не тільки оптимізація, це ще й чистота даних: ви не витягуєте зайві приватні поля.
Що робити, якщо відносини “важкі”. Наприклад, hasMany на 50 рядків може притягнути тисячі пов’язаних записів. Якщо вам треба показати останній коментар або перше медіа — не тягніть всі. Використовуйте окрему модель логіки: або eager load лише один пов’язаний (через latestOfMany/oldestOfMany), або підтягуйте тільки count, або робіть окремий endpoint/модальне вікно для деталей. На сторінці списку має бути “легка” інформація.
Тепер — профілювання запитів. Профілювання — це вимірювання: скільки запитів виконується, скільки часу вони займають, які з них найповільніші, і що саме їх робить повільними (індекси, join, сортування). Без вимірювання ви завжди будете “оптимізувати на око”, а це майже завжди марно або шкідливо.
На базовому рівні профілювання починається з простого: на сторінці ви повинні вміти відповісти на три питання. Скільки SQL-запитів виконалося? Який сумарний час на SQL? Які топ-3 найповільніші запити? Якщо ви можете це побачити локально/на staging, ви відразу ловите N+1 і важкі запити.
Найкраща дисципліна — робити профілювання перед і після правки. Наприклад, ви додали колонку “Автор” у таблицю. До: 8 запитів, 40 мс. Після: 108 запитів, 300 мс. Це сигнал N+1. Додаєте with('author'): стає 9 запитів, 60 мс — перемога. Це і є правильний цикл оптимізації: маленька зміна → замір → виправлення → замір.
Окрема техніка — “контроль N+1 як правило”. Тобто ви встановлюєте для себе норму: сторінка списку не повинна робити десятки запитів тільки через зв’язки. Якщо в списку 50 рядків, кількість запитів повинна бути приблизно сталою, а не рости лінійно з кількістю рядків. Це головний маркер. Якщо кількість запитів росте разом з perPage — у вас N+1.
Ще одна важлива пастка — N+1 через accessors і resources/transformers. У Laravel часто роблять красиві атрибути, які всередині читають зв’язки. Якщо в контролері ви не eager load ці зв’язки, ви отримуєте N+1 непомітно. Тому правило: якщо accessor або ресурс використовує relationship, контролер/репозиторій зобов’язаний eager load цей relationship. Інакше ви “привозите” проблему в кожне місце, де цей accessor використають.
Профілювання також допомагає зрозуміти, коли проблема не в N+1, а в одному “важкому” запиті. Наприклад, сторінка робить всього 5 запитів, але один займає 800 мс через відсутність індексу або filesort. Тут eager loading не допоможе, тут треба індекси, правильний WHERE, правильний порядок умов або обмеження сортування.
Щоб профілювання було корисним, треба дивитися не тільки “час Laravel”, а саме SQL. Велика частина повільності адмінок — це БД. І якщо ви навчитеся регулярно дивитися на запити, ви відразу почнете писати код інакше: менше зайвих select, менше “рахувати в циклі”, більше withCount, більше індексів під фільтри.
Висновок: контроль N+1 — це правило “кількість запитів не має рости з кількістю рядків”. Для цього ви прибираєте lazy loading через with() і замінюєте циклічні count()/exists() на withCount() або інші агрегати. Профілювання — це ваша зброя проти самообману: ви вимірюєте кількість і час запитів, знаходите найважчі, і виправляєте причину, а не “прикручуєте кеш навмання”. Якщо привчити до цього з другого місяця навчання, адмінка залишиться швидкою навіть тоді, коли даних стане багато.