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

Кеш результатів запитів: Eloquent під тегами

Колись звичайний день на сервері виглядав так: процесор origin-машини впирався у 100%, черга Horizon роздувалася, а в Sentry сипалися таймаути. Жодного сплеску відвідувачів-людей при цьому не було. Винуватцем виявився трафік AI-краулерів - боти OpenAI, Anthropic та інших обходили сайт зі швидкістю майже мільйон запитів на добу переважно з US-адрес. Кожен такий запит - це повний рендер сторінки на Livewire, похід у базу, гідрація моделей. Origin технічно витримував, але робив це ціною всіх вільних воркерів.

Замість того щоб блокувати ботів (вони нам корисні - завдяки їм контент потрапляє у відповіді асистентів), ми побудували два незалежні рівні кешування. Кожен перехоплює запит на своєму рубежі. Разом вони знизили завантаження CPU на origin зі 100% до приблизно 20%.

У попередніх частинах ми прибирали зайві запити - у зв'язках і в списках. Тепер беремося за ті, що лишилися: важкі блоки на кшталт «ТОП компаній» усе одно доведеться виконати, тож їх варто закешувати. Далі в серії йдуть три статті про кешування; ця - перша, про найглибший його рівень.

Два рівні: загальна картина

Уявіть запит до сервера як воду, яку треба зупинити якомога раніше. На шляху дві греблі:

  • Рівень 1 - Cloudflare edge. Кешує цілу HTML-сторінку в дата-центрах CF по всьому світу. Латентність ~40 мс, origin узагалі не викликається. Інвалідація - точкова, за тегами сутностей. Це тема двох наступних частин.
  • Рівень 2 - кеш запитів (query cache). Кешує результати окремих Eloquent-запитів у Redis (tagged). Працює, коли сторінка таки рендериться. Це тема цієї частини.

Ключова ідея: рівні незалежні. Якщо ми вимикаємо Cloudflare, сайт працює - просто більше запитів доходить до рендера, де їх перехоплює кеш запитів. Кожен шар - окремий запобіжник, а не ланка в крихкому ланцюгу.

Важливо одразу розвести два рівні, бо для авторизованих вони поводяться по-різному. Edge кешує цілу сторінку, тож працює лише для гостей: залогінений користувач завжди отримує свіжо відрендерений HTML з персональною шапкою. А ось кеш запитів працює завжди, і для гостей, і для залогінених - він кешує не цілу сторінку, а лише ті блоки, що однакові для всіх (списки вакансій, статей, подій, топ-компаній). На головній - найважчій сторінці - для гостя це знімає кількість запитів до бази з 80 на «холодній» сторінці до 0 на «гарячій»: усе, що він бачить, спільне для всіх. Під залогіненим користувачем лишаються тільки суто персональні запити (поточний користувач, його ролі, його обране, аватар) - на головній це близько 4.

Почнемо знизу - з рівня, що працює всередині самого рендера.

Рівень 2: кеш результатів запитів

Навіть коли сторінка таки доходить до рендера, не обов'язково щоразу ходити в базу за тими самими даними. Блок «популярні теги», список категорій, добірка останніх відео, featured-стаття - усе це читається на кожен запит, а змінюється незрівнянно рідше: список категорій - майже ніколи, теги й featured - час від часу. Саме цей розрив між частотою читання і частотою зміни робить їх ідеальними кандидатами на кеш.

Проблема стандартного кешу - інвалідація. Покласти результат у Cache::remember() легко; складно не забути його скинути, коли дані змінилися. Тому ми не розкидали виклики кешу по коду, а звели їх до двох маленьких абстракцій: трейта Cacheable на моделях і хелпера Cached на місцях використання.

Трейт Cacheable: кожна модель має свій тег

Ідея проста: кожен тип моделі отримує власний іменований простір у кеші (cache tag), і щоразу, коли модель зберігається чи видаляється, цей простір автоматично скидається. Тоді на місці використання достатньо покласти результат під правильний тег - і про інвалідацію можна не думати.

trait Cacheable
{
  // Laravel автоматично викликає boot{TraitName}, тож підключення трейта
  // мовчки підписує модель на її власні події saved/deleted/restored.
  public static function bootCacheable(): void
  {
    $flush = static function (self $model): void {
      $model->flushCache();
    };

    static::saved($flush);
    static::deleted($flush);

    if (method_exists(static::class, 'restored')) {
      static::restored($flush);
    }
  }

  // Тег, що утворює простір кешу для цього типу моделі.
  // За замовчуванням - множина в kebab-case від назви класу (Post -> "posts").
  public static function cacheTag(): string
  {
    return Str::of(class_basename(static::class))->plural()->kebab()->value();
  }

  // Усі cache-теги, на які впливає зміна цієї моделі.
  public function cacheTags(): array
  {
    return array_values(array_unique([
      static::cacheTag(),
      ...$this->relatedCacheTags(),
    ]));
  }

  public function flushCache(): void
  {
    Cache::tags($this->cacheTags())->flush();
  }
}

Найважливіше тут - метод bootCacheable(). Laravel автоматично викликає boot{TraitName} під час завантаження моделі, тож додавання трейта Cacheable мовчки підписує модель на власні події saved/deleted/restored. Жодного коду в самій моделі - підключив трейт, і інвалідація працює.

Перехресні залежності: relatedCacheTags()

Складність реального світу в тому, що похідні від однієї сутності просочуються в кеш іншої. На головній є блок «ТОП компаній» - компанії, відсортовані за кількістю опублікованих вакансій. Він закешований під тегами обох моделей. Отже, коли з'являється чи закривається вакансія, у компанії змінюється лічильник вакансій, а отже - склад і порядок цього блоку. Скинути лише тег vacancies замало: блок живе під тегом companies, і без його скидання топ покаже застарілий порядок.

Саме для цього є relatedCacheTags(). Ось як це виглядає на моделі Vacancy:

// Блок "ТОП компаній" сортує компанії за кількістю їхніх вакансій,
// тож зміна вакансії має скидати й кеш, позначений тегом компаній.
public function relatedCacheTags(): array
{
  return [Company::cacheTag()];
}

А на боці використання той блок кешується одразу під двома тегами, щоб його скидала зміна будь-якої з двох сутностей:

'topCompanies' => Cached::remember(
  [Company::cacheTag(), Vacancy::cacheTag()],
  'home.top-companies',
  fn () => Company::query()
    ->published()
    ->whereHas('publishedVacancies')
    ->withCount('publishedVacancies')
    ->orderByDesc('published_vacancies_count')
    ->limit(4)
    ->get(),
),

Тепер збереження вакансії скидає обидва простори. Залежності декларуються в одному місці - на моделі, якій вони притаманні, - а не розмазуються по контролерах.

Хелпер Cached: тонка обгортка над tagged-кешем

З боку використання все ще простіше. Cached - тонка обгортка, яка лише фіксує дефолтний TTL і змушує завжди передавати теги явно:

final class Cached
{
  public const int TTL = 3600;

  public static function remember(array $tags, string $key, Closure $callback, ?int $ttl = null): mixed
  {
    return Cache::tags($tags)->remember($key, $ttl ?? self::TTL, $callback);
  }

  public static function flush(array $tags): void
  {
    Cache::tags($tags)->flush();
  }
}

Здавалося б, навіщо обгортка над Cache::tags()->remember()? Заради дисципліни. Сигнатура remember(array $tags, ...) робить теги першим обов'язковим аргументом - неможливо випадково покласти щось у кеш без тега, а отже, без шляху до інвалідації. Це маленьке тертя економить години відлагодження «чомусь не оновлюється».

(Далі в серії ми побачимо, що цей самий Cached::remember робить ще одну роботу - збирає теги для Cloudflare. Один словник тегів обслуговує обидва рівні кешу.)

Що ми свідомо НЕ кешуємо

Кеш - не безкоштовний: кожен запис треба десь тримати й вчасно інвалідувати. Тож не менш важливо вміти сказати «тут кешувати не варто». Показовий приклад - сторінка окремої компанії, де є блок із п'ятьма її свіжими вакансіями:

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

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

Дешевий запит + рідкісний рендер + часта інвалідація = кеш тут лише додасть навантаження на Redis і складності, майже нічого не заощадивши. Тому вакансії компанії ми не кешуємо навмисно.

Що на цій сторінці справді варто було прибрати - то це не кеш, а два окремі COUNT у хедері (підписники й коментарі). Замість двох запитів на кожен холодний рендер їх рахуємо одним проходом через loadCount ще під час резолву моделі:

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

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

Підступний баг: «працює на array, падає на Redis»

Тепер - історія, яка коштувала нам кількох годин і яку варто знати кожному, хто кешує Eloquent-об'єкти.

Усі наші тести проходили зеленими. Локально все працювало. А на проді сторінки інколи падали з загадкою class definition App\Models\Post ... loaded before unserialize(), або об'єкти поверталися з кешу як __PHP_Incomplete_Class - порожні заглушки замість моделей. Причому тільки на «теплому» Redis - на першому (холодному) запиті все було гаразд, а в тестах помилка не відтворювалася взагалі.

Причина - у безпековому дефолті PHP. Коли Laravel читає значення з Redis, він викликає unserialize() з опцією allowed_classes. За замовчуванням config/cache.php ставить serializable_classes => false, а це означає: unserialize($value, ['allowed_classes' => false]). З таким налаштуванням будь-який об'єкт повертається як __PHP_Incomplete_Class. Кешувати колекції чи моделі стає неможливо.

Чому це безпековий дефолт? Якщо APP_KEY або сам Redis колись витечуть, зловмисник зможе підкласти в кеш серіалізований об'єкт із небезпечним «магічним» методом і запустити gadget-chain аж до RCE. allowed_classes => false цьому запобігає.

Чому тести мовчали? Бо в тестах драйвер кешу - array, який тримає об'єкти в пам'яті й узагалі не серіалізує. Помилка живе виключно на реальному Redis і тільки після того, як значення вже потрапило в кеш (звідси «тепло»).

Рішення - не вимикати захист повністю, а замінити false на білий список дозволених класів:

// config/cache.php
'serializable_classes' => [
  // Контейнери колекцій і pivot-и, що обгортають кешовані результати.
  Illuminate\Database\Eloquent\Collection::class,
  Illuminate\Database\Eloquent\Relations\Pivot::class,
  Illuminate\Database\Eloquent\Relations\MorphPivot::class,
  Illuminate\Pagination\LengthAwarePaginator::class,
  Illuminate\Support\Carbon::class,
  Illuminate\Support\Collection::class,

  // Кешовані моделі та зв'язки, що eager-load'яться поруч.
  App\Models\Category::class,
  App\Models\Company::class,
  App\Models\Post::class,
  App\Models\Tag::class,
  App\Models\Vacancy::class,
  // ... решта моделей
],

Так ми зберігаємо захист практично недоторканим - відновити з кешу можна тільки наші власні моделі, у яких немає небезпечних магічних методів - і водночас отримуємо змогу кешувати гідровані результати запитів.

Дві грабельки, на які легко наступити при розширенні білого списку:

  1. Не забудьте контейнери, а не лише моделі. Якщо ви кешуєте ->paginate(), у списку має бути LengthAwarePaginator. Якщо ->groupBy() - Illuminate\Support\Collection. Якщо в моделі є datetime-каст - Illuminate\Support\Carbon.
  2. Spatie MediaLibrary має власний клас колекції. Зв'язок media повертає не звичайну Eloquent-колекцію, а Spatie\MediaLibrary\MediaCollections\Models\Collections\MediaCollection. Її теж треба внести в список - інакше падіння тільки на тих сторінках, де рендеряться обкладинки чи аватари.

Щоб ця помилка не поверталася, у нас є тест-вартовий CacheableTraitTest, який падає, якщо до проєкту додали Cacheable-модель і забули вписати її в serializable_classes.

Що далі

Отже, найглибший рівень готовий: важкі запити кешуються в Redis під тегами моделей, а інвалідація відбувається сама собою через трейт Cacheable. Це знімає навантаження з бази навіть тоді, коли сторінка таки рендериться.

Але рендерити цілу сторінку на Livewire щоразу - усе одно дорого. У наступній частині ми піднімемося на верхній рівень - Cloudflare edge, де відповідь віддається з дата-центру за ~40 мс і PHP не виконується зовсім. Там на нас чекає найцікавіше: як автоматично позначити кожну сторінку тегами сутностей, що на ній є, - щоб згодом скидати з edge лише ті сторінки, що реально змінилися.

60

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

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

3 / 3

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

Коментарі

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

Будьте першим, хто залишить коментар!

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

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

Alliance Digital Нова
Сьогодні

PHP Developer

Middle PHP-розробник для фінтех-продукту. Розробка backend на Laravel з PostgreSQL, RabbitMQ, Redis. Реалізація складної бізнес-логіки кредитування, фінансових операцій, зовнішніх інтеграцій. Вимоги: 3+ років PHP, впевнена робота з Laravel, OOP/SOLID, складна бізнес-логіка, PostgreSQL, черги, тестування, production-mindset.

Hurma System Tech Нова
Сьогодні

Full Stack Developer (PHP, Laravel, Vue.js)

Full Stack Developer на PHP та Laravel з фронтенд-частиною на Vue.js. Розроблення та рефакторинг функціоналу HR-системи, робота з БД, REST API, тестування. Вимоги: PHP 8+, 2+ років Laravel, Vue.js, OOP, SQL, Git, базовий TypeScript. Віддалена робота з гнучким графіком.

SendPulse Нова
Сьогодні

Middle+ PHP Developer (with DevOps)

Middle+ PHP Developer з DevOps-експертизою для мультикоманди, що розробляє CRM та освітню платформу. Основна роль: проєктування й реалізація модулів на PHP 8 і Laravel, оптимізація під високе навантаження, розробка інтеграцій. DevOps-компонент: налаштування CI/CD, контейнеризація (Docker/Kubernetes), управління AWS-інфраструктурою. Вимоги: глибока експертиза Laravel, Docker, Kubernetes, Git, технічна англійська.

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

Bagisto

bagisto/bagisto

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

27,987 v2.4.10 12 21

Lang

laravel-lang/lang

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

7,776 15.34.3 10