---
title: "Кеш результатів запитів: Eloquent під тегами"
url: https://laravelukraine.com/blog/kes-rezultativ-zapitiv-eloquent-pid-tegami
date: 2026-08-13
---

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

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

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

У [попередніх частинах](https://laravelukraine.com/blog/prixovanii-n1-u-livewire-rejestri-na-cas-zapitu) ми прибирали зайві запити - у зв'язках і в списках. Тепер беремося за ті, що лишилися: важкі блоки на кшталт «ТОП компаній» усе одно доведеться виконати, тож їх варто закешувати. Далі в серії йдуть три статті про кешування; ця - перша, про найглибший його рівень.

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

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

- **Рівень 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 лише ті сторінки, що реально змінилися.
