State - поведінка залежно від стану
Змінює поведінку об'єкта залежно від стану, прибираючи каскади if. Замовлення, підписки, оплати та пакет spatie/laravel-model-states.
Почніть вводити, щоб шукати по статтях, вакансіях, компаніях, проєктах, ресурсах, пакетах, подіях і користувачах.
Це перша стаття з циклу про те, як влаштована продуктивність laravelukraine.com. І почнемо з найпростішої, але найважливішої думки: найдешевший запит - той, якого немає. Перш ніж кешувати навантаження, варто спитати, чи створюємо ми його даремно. Найпоширеніше джерело зайвих запитів - N+1 у зв'язках моделей, від якого рятує класичний eager loading. Здавалося б, тема з першого туторіала по Eloquent, та на реальному проєкті вона має пастки, про які там не пишуть.
Список постів у блозі показує для кожного посту автора, категорію, теги й обкладинку. Якщо не подбати про завантаження зв'язків, Eloquent зробить окремий запит на кожен зв'язок кожного рядка: 20 постів × (автор + категорія + теги + медіа) - це десятки запитів там, де мало бути кілька. Ліки відомі - ->with():
$query = Post::query()
->published()
->with(['author', 'author.media', 'category', 'tags', 'media', 'series', 'seriesTopic.series'])
->withCount('comments');
Один запит на пости, і по одному додатковому - на кожен тип зв'язку для всієї сторінки одразу, а не для кожного рядка. Це нецікаво рівно доти, доки не натрапиш на два неочевидні місця.
Найпідступніший зв'язок у цьому списку - 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'ити треба не «модель», а весь ланцюг звернень, які зробить в'юха - включно з тими зв'язками, що ховаються за зручними хелперами пакетів.
Друга поширена помилка - рахувати пов'язані сутності «вручну» в циклі або через окремий запит на рядок. Скільки в цієї компанії підписників? Скільки вакансій? Наївно - $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 і складності, майже нічого не заощадивши. Кожен шар кешу - це запис, який треба десь тримати й вчасно скидати; він виправданий, лише коли економить більше, ніж коштує. Тому вакансії компанії ми свідомо не кешуємо: дешевше зробити дешевий запит, ніж тримати й інвалідувати ще один запис у кеші.
with(['author']) замало, якщо в'юха бере аватар: зв'язок media схований за хелпером пакета. Явно вкладайте author.media (і глибше, якщо потрібно).withCount/loadCount, не в циклі. База порахує все одним проходом; ручний count() на рядок - той самий N+1 в іншій обгортці.Цей перший вид зайвого - N+1 у зв'язках - лікується eager loading'ом просто, бо він на видноті. Але є підступніший різновид, який ховається в самій архітектурі фронтенду. У наступній частині - прихований N+1, коли кожен компонент у списку тихо робить власний запит, і як його прибрати реєстром на час запиту.
1 / 2
Увійдіть, щоб залишити коментар
В проєктах, де дуже багато звязків, я зазвичай блокую lazyloading на рівні сервіспровайдера, Model::preventLazyLoading(! $this->app->isProduction());. Після цього, якщо забуду про eager loading, ріспонс видасть 500. Це також дисциплінує, не забувати про eager loading, там де це потрібно.
Змінює поведінку об'єкта залежно від стану, прибираючи каскади if. Замовлення, підписки, оплати та пакет spatie/laravel-model-states.
Найпідступніше джерело зайвих запитів - список із однаковою кнопкою на кожній картці, де кожна тихо робить свій SELECT. Розбираємо, як per-request реєстр збирає сотні однакових перевірок в один запит, чому саме scoped(), і той самий патерн поза базою.
Senior PHP Backend Developer з фокусом на AI-інструменти. Розробка масштабованих Laravel backend-додатків з TDD підходом (Pest/PHPUnit), розгортання на GCP (Cloud Run, Cloud SQL), оптимізація бази даних та написання високобезпечного коду. Вимагається 5+ років досвіду PHP/Laravel, hands-on GCP, практичний досвід AI-агентів (Gemini, Copilot, Cursor) для прискорення розробки.
Backend Engineer для AI-платформи логістики на PHP/Laravel. Розробка високонавантажених сервісів, API, event-driven пайплайнів для обробки сотень тисяч транзакцій щодня. Стек: PHP 8.x, Laravel, PostgreSQL, Redis, Horizon. Вимоги: 3+ років досвіду, глибоке розуміння Laravel, оптимізація високонавантажених систем, роботи з AI модулями. Самостійність і проактивність обов'язкові.
Middle PHP розробник для SaaS стартапу розробляє та оптимізує веб-додатки на Laravel, SQL/NoSQL, JavaScript та Bootstrap. Вимагається 3+ років досвіду з PHP/Laravel, глибоке розуміння баз даних, навички OOP та чистого коду. Повна віддаленість (часовий пояс Нью-Йорка), робота в швидкому стартап-середовищі з фокусом на масштабованість та якість кода.
Bagisto — це платформа для електронної комерції, побудована на Laravel. Вона надає готове рішення для створення та управління інтернет-магазинами з підтримкою каталогу товарів, замовлень, платежів та клієнтів.