Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама
Найдешевший запит - той, якого немає: eager loading і лічильники
Посібники 20 липня 2026

Найдешевший запит - той, якого немає: 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, коли кожен компонент у списку тихо робить власний запит, і як його прибрати реєстром на час запиту.

1 99

Ця стаття - частина серії

Як будувався laravelukraine.com

1 / 3

  1. 1 Найдешевший запит - той, якого немає: eager loading і лічильники
  2. 2 Прихований N+1 у Livewire: реєстри на час запиту
  3. 3 Кеш результатів запитів: Eloquent під тегами

Читати в документації

Коментарі (1)

Увійдіть, щоб залишити коментар

Oleksandr Lehotskyy
Oleksandr Lehotskyy 1 місяць тому

В проєктах, де дуже багато звязків, я зазвичай блокую lazyloading на рівні сервіспровайдера, Model::preventLazyLoading(! $this->app->isProduction());. Після цього, якщо забуду про eager loading, ріспонс видасть 500. Це також дисциплінує, не забувати про eager loading, там де це потрібно.

Читайте також

Патерн Null Object
Посібники 10 вересня 2026
Патерни проєктування

Null Object - поведінка за замовчуванням

Замінює null безпечним об'єктом із тим самим інтерфейсом і "порожньою" поведінкою. Прибирає перевірки на null - і де він може приховати справжню проблему.

4

Вакансії за темою

Гарант-Інфо
4 дні тому

Middle PHP Developer (Laravel)

Middle PHP Developer для міжнародного B2B-продукту у телекомунікаціях. Розробка нового функціоналу на PHP 8.x/Laravel, підтримка кодової бази, робота з REST API, платіжними інтеграціями та MySQL. Вимоги: 2+ років комерційного досвіду PHP, впевнене знання Laravel, ООП, SOLID, тестування (PHPUnit/Pest), Git, Docker.

Full Stack Developer (PHP, React, Middle, Middle+)

Full Stack розробник для підтримки та розвитку аналітичного продукту перевірки контрагентів. Робота зі складною бізнес-логікою, базами даних та інтеграцією AI-рішень. Стек: PHP 8.x (Laravel/Symfony), React, MySQL, REST API. Вимоги: 3+ років комерційного досвіду, глибоке розуміння SQL, Git, Docker, CI/CD, Linux, OWASP.

Програміст PHP (інтерн)

Вакансія на посаду інтерна PHP розробника для початківців з теоретичною базою ООП та базовими знаннями PHP. Потрібні навички Git/GitHub, власні проєкти. Стажування в офісі під керівництвом менторів з перспективою переходу на посаду Junior разробника.

Пакети за темою

Bagisto

bagisto/bagisto

Bagisto — це платформа для електронної комерції, побудована на Laravel. Вона надає готове рішення для створення та управління інтернет-магазинами з підтримкою каталогу товарів, замовлень, платежів та клієнтів.

28,086 v2.5.0-beta1 13 25

Lang

laravel-lang/lang

Список 126 мов для Laravel Framework, Laravel Jetstream, Laravel Fortify, Laravel Breeze, Laravel Cashier, Laravel Nova, Laravel Spark та Laravel UI.

7,774 15.34.8 11