---
title: "Найдешевший запит - той, якого немає: eager loading і лічильники"
url: https://laravelukraine.com/blog/naidesevsii-zapit-toi-iakogo-nemaje-eager-loading-i-licilniki
author: "Олексій Бабінцев"
date: 2026-07-20
---

# Найдешевший запит - той, якого немає: eager loading і лічильники

Це перша стаття з циклу про те, як влаштована продуктивність laravelukraine.com. І почнемо з найпростішої, але найважливішої думки: **найдешевший запит - той, якого немає**. Перш ніж кешувати навантаження, варто спитати, чи створюємо ми його даремно. Найпоширеніше джерело зайвих запитів - N+1 **у зв'язках моделей**, від якого рятує класичний eager loading. Здавалося б, тема з першого туторіала по Eloquent, та на реальному проєкті вона має пастки, про які там не пишуть.

### N+1 у зв'язках: класика і як її не проґавити

Список постів у блозі показує для кожного посту автора, категорію, теги й обкладинку. Якщо не подбати про завантаження зв'язків, Eloquent зробить окремий запит на **кожен** зв'язок **кожного** рядка: 20 постів × (автор + категорія + теги + медіа) - це десятки запитів там, де мало бути кілька. Ліки відомі - `->with()`:

```
$query = Post::query()
    ->published()
    ->with(['author', 'author.media', 'category', 'tags', 'media', 'series', 'seriesTopic.series'])
    ->withCount('comments');
```

Один запит на пости, і по одному додатковому - на кожен тип зв'язку **для всієї сторінки одразу**, а не для кожного рядка. Це нецікаво рівно доти, доки не натрапиш на два неочевидні місця.

### Пастка 1: аватари через Spatie MediaLibrary

Найпідступніший зв'язок у цьому списку - `author.media`. Аватар автора в нас зберігається через Spatie MediaLibrary, а вона тримає файли в окремій таблиці `media` з поліморфним зв'язком. Тонкість у тім, що звернення до аватара виглядає як звичайне читання властивості - `$post->author->getFirstMediaUrl('avatar')`, - і **не схоже на запит**. Але за цим викликом ховається звернення до зв'язку `media` моделі автора.

Тобто мало завантажити `author` - треба завантажити ще й `author.media`. Забудеш вкладене `.media` - і отримаєш N+1, який ще й важче помітити, ніж звичайний: `author` завантажений, IDE не свариться, автор рендериться - а на кожен аватар усе одно летить прихований запит у `media`. Саме тому в списку явно стоїть `author.media`, а не просто `author`. Той самий подвійний зв'язок бачимо всюди, де показуємо людей з аватарами - у коментарях навіть на два рівні вглиб:

```
// Коментарі: автор і його аватар, плюс автори відповідей та їхні аватари
->with(['author', 'author.media', 'reactions', 'replies.author', 'replies.author.media', 'replies.reactions'])
```

Урок: **eager-load'ити треба не «модель», а весь ланцюг звернень, які зробить в'юха** - включно з тими зв'язками, що ховаються за зручними хелперами пакетів.

### Пастка 2: лічильники - це теж запити

Друга поширена помилка - рахувати пов'язані сутності «вручну» в циклі або через окремий запит на рядок. Скільки в цієї компанії підписників? Скільки вакансій? Наївно - `$company->subscribers()->count()` на кожну картку, знову N+1. Правильно - попросити базу порахувати все одним проходом через `withCount`:

```
$query = Company::query()
    ->published()
    ->with(['media', 'tags'])
    ->withCount(['subscribers', 'publishedVacancies', 'comments']);
```

`withCount` додає до основного запиту підзапити-лічильники, і кожна модель отримує готові `subscribers_count`, `published_vacancies_count`, `comments_count` без жодного зайвого походу в базу. А коли модель **уже** завантажена (детальна сторінка, де об'єкт прийшов роутером), той самий трюк робить `loadCount` пост-фактум:

```
// У mount() сторінки компанії, коли її вже розв'язано роутером:
$company->loadCount(['subscribers', 'comments']);
```

Це прибирає два окремі `COUNT`-запити в хедері, які інакше виконувалися б на кожен рендер, - і, що важливо, **без жодного кешу й потреби щось інвалідувати**. Часто найдешевша оптимізація - не «закешувати важке», а «порахувати за один прохід замість трьох».

### Коли навіть кеш - зайвий шар

І тут ми забігаємо наперед - до кешування, яким займемося далі в серії. Розібравшись, що запит дешевий (eager-loaded зв'язки, `withCount` замість ручних лічильників), починаєш бачити випадки, де **кешувати теж не варто**. Показовий - блок із п'ятьма свіжими вакансіями на сторінці компанії:

```
'vacancies' => $this->company->publishedVacancies()
    ->with(['tags', 'company.media'])
    ->latest('published_at')
    ->take(5)
    ->get(),
```

Спокусливо обгорнути це в кеш. Але порахуймо. Для гостя - а це майже весь трафік - сторінка віддається з edge Cloudflare, тож PHP до цього запиту навіть не доходить. Сам запит дешевий: `take(5)` по індексу. Ключ кешу був би на кожну компанію окремо - багато дрібних записів у Redis заради рідкісних рендерів. А інвалідація тега `vacancies` часта, тож такий кеш протухав би частіше, ніж читався.

**Дешевий запит + рідкісний рендер (для гостя - взагалі 0) + часта інвалідація = кеш тут лише додасть навантаження на Redis і складності, майже нічого не заощадивши.** Кожен шар кешу - це запис, який треба десь тримати й вчасно скидати; він виправданий, лише коли економить більше, ніж коштує. Тому вакансії компанії ми свідомо **не** кешуємо: дешевше зробити дешевий запит, ніж тримати й інвалідувати ще один запис у кеші.

### Головні висновки

1. **Eager-load'те ланцюг звернень, а не «модель».** `with(['author'])` замало, якщо в'юха бере аватар: зв'язок `media` схований за хелпером пакета. Явно вкладайте `author.media` (і глибше, якщо потрібно).
2. **Аватари Spatie MediaLibrary - типове джерело прихованого N+1.** Звернення до медіа не схоже на запит, тож його легко проґавити. Перевіряйте профайлером саме сторінки зі списками людей.
3. **Лічильники - через `withCount`/`loadCount`, не в циклі.** База порахує все одним проходом; ручний `count()` на рядок - той самий N+1 в іншій обгортці.
4. **Дешевий запит не треба кешувати.** Кеш виправданий економією, а не рефлексом. Якщо запит дешевий, рендер рідкісний, а інвалідація часта - кеш лише додає навантаження й складності.
5. **«Не роби зайвого» - наскрізний принцип усієї серії.** Спершу приберіть зайві запити (eager loading, лічильники, а далі й приховані N+1 у списках), і лише те, що лишилося дорогим і частим, віддавайте кешу - на потрібному рубежі.

Цей перший вид зайвого - N+1 у зв'язках - лікується eager loading'ом просто, бо він на видноті. Але є підступніший різновид, який ховається в самій архітектурі фронтенду. У наступній частині - прихований N+1, коли кожен компонент у списку тихо робить власний запит, і як його прибрати реєстром на час запиту.
