Event Sourcing - події як джерело істини
Зберігає не поточний стан, а послідовність подій, що його змінювали. Повний аудит і відтворюваність - ціною складнішої інфраструктури.
Почніть вводити, щоб шукати по статтях, вакансіях, компаніях, проєктах, ресурсах, пакетах, подіях і користувачах.
Колись звичайний день на сервері виглядав так: процесор origin-машини впирався у 100%, черга Horizon роздувалася, а в Sentry сипалися таймаути. Жодного сплеску відвідувачів-людей при цьому не було. Винуватцем виявився трафік AI-краулерів - боти OpenAI, Anthropic та інших обходили сайт зі швидкістю майже мільйон запитів на добу переважно з US-адрес. Кожен такий запит - це повний рендер сторінки на Livewire, похід у базу, гідрація моделей. Origin технічно витримував, але робив це ціною всіх вільних воркерів.
Замість того щоб блокувати ботів (вони нам корисні - завдяки їм контент потрапляє у відповіді асистентів), ми побудували два незалежні рівні кешування. Кожен перехоплює запит на своєму рубежі. Разом вони знизили завантаження CPU на origin зі 100% до приблизно 20%.
У попередніх частинах ми прибирали зайві запити - у зв'язках і в списках. Тепер беремося за ті, що лишилися: важкі блоки на кшталт «ТОП компаній» усе одно доведеться виконати, тож їх варто закешувати. Далі в серії йдуть три статті про кешування; ця - перша, про найглибший його рівень.
Уявіть запит до сервера як воду, яку треба зупинити якомога раніше. На шляху дві греблі:
Ключова ідея: рівні незалежні. Якщо ми вимикаємо Cloudflare, сайт працює - просто більше запитів доходить до рендера, де їх перехоплює кеш запитів. Кожен шар - окремий запобіжник, а не ланка в крихкому ланцюгу.
Важливо одразу розвести два рівні, бо для авторизованих вони поводяться по-різному. Edge кешує цілу сторінку, тож працює лише для гостей: залогінений користувач завжди отримує свіжо відрендерений HTML з персональною шапкою. А ось кеш запитів працює завжди, і для гостей, і для залогінених - він кешує не цілу сторінку, а лише ті блоки, що однакові для всіх (списки вакансій, статей, подій, топ-компаній). На головній - найважчій сторінці - для гостя це знімає кількість запитів до бази з 80 на «холодній» сторінці до 0 на «гарячій»: усе, що він бачить, спільне для всіх. Під залогіненим користувачем лишаються тільки суто персональні запити (поточний користувач, його ролі, його обране, аватар) - на головній це близько 4.
Почнемо знизу - з рівня, що працює всередині самого рендера.
Навіть коли сторінка таки доходить до рендера, не обов'язково щоразу ходити в базу за тими самими даними. Блок «популярні теги», список категорій, добірка останніх відео, featured-стаття - усе це читається на кожен запит, а змінюється незрівнянно рідше: список категорій - майже ніколи, теги й featured - час від часу. Саме цей розрив між частотою читання і частотою зміни робить їх ідеальними кандидатами на кеш.
Проблема стандартного кешу - інвалідація. Покласти результат у Cache::remember() легко; складно не забути його скинути, коли дані змінилися. Тому ми не розкидали виклики кешу по коду, а звели їх до двох маленьких абстракцій: трейта Cacheable на моделях і хелпера Cached на місцях використання.
Ідея проста: кожен тип моделі отримує власний іменований простір у кеші (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. Жодного коду в самій моделі - підключив трейт, і інвалідація працює.
Складність реального світу в тому, що похідні від однієї сутності просочуються в кеш іншої. На головній є блок «ТОП компаній» - компанії, відсортовані за кількістю опублікованих вакансій. Він закешований під тегами обох моделей. Отже, коли з'являється чи закривається вакансія, у компанії змінюється лічильник вакансій, а отже - склад і порядок цього блоку. Скинути лише тег 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 - тонка обгортка, яка лише фіксує дефолтний 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']);
Це прибирає два запити без жодного кешу й без потреби щось інвалідувати - часто найдешевша оптимізація не «закешувати важке», а «не робити зайвого».
Тепер - історія, яка коштувала нам кількох годин і яку варто знати кожному, хто кешує 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,
// ... решта моделей
],
Так ми зберігаємо захист практично недоторканим - відновити з кешу можна тільки наші власні моделі, у яких немає небезпечних магічних методів - і водночас отримуємо змогу кешувати гідровані результати запитів.
Дві грабельки, на які легко наступити при розширенні білого списку:
->paginate(), у списку має бути LengthAwarePaginator. Якщо ->groupBy() - Illuminate\Support\Collection. Якщо в моделі є datetime-каст - Illuminate\Support\Carbon.media повертає не звичайну Eloquent-колекцію, а Spatie\MediaLibrary\MediaCollections\Models\Collections\MediaCollection. Її теж треба внести в список - інакше падіння тільки на тих сторінках, де рендеряться обкладинки чи аватари.Щоб ця помилка не поверталася, у нас є тест-вартовий CacheableTraitTest, який падає, якщо до проєкту додали Cacheable-модель і забули вписати її в serializable_classes.
Отже, найглибший рівень готовий: важкі запити кешуються в Redis під тегами моделей, а інвалідація відбувається сама собою через трейт Cacheable. Це знімає навантаження з бази навіть тоді, коли сторінка таки рендериться.
Але рендерити цілу сторінку на Livewire щоразу - усе одно дорого. У наступній частині ми піднімемося на верхній рівень - Cloudflare edge, де відповідь віддається з дата-центру за ~40 мс і PHP не виконується зовсім. Там на нас чекає найцікавіше: як автоматично позначити кожну сторінку тегами сутностей, що на ній є, - щоб згодом скидати з edge лише ті сторінки, що реально змінилися.
3 / 3
Зберігає не поточний стан, а послідовність подій, що його змінювали. Повний аудит і відтворюваність - ціною складнішої інфраструктури.
Розділяє операції зміни стану (команди) та читання (запити) на окремі моделі. Коли це виправдано у великих доменах і чому надмірне для CRUD.
Middle PHP-розробник для фінтех-продукту. Розробка backend на Laravel з PostgreSQL, RabbitMQ, Redis. Реалізація складної бізнес-логіки кредитування, фінансових операцій, зовнішніх інтеграцій. Вимоги: 3+ років PHP, впевнена робота з Laravel, OOP/SOLID, складна бізнес-логіка, PostgreSQL, черги, тестування, production-mindset.
Full Stack Developer на PHP та Laravel з фронтенд-частиною на Vue.js. Розроблення та рефакторинг функціоналу HR-системи, робота з БД, REST API, тестування. Вимоги: PHP 8+, 2+ років Laravel, Vue.js, OOP, SQL, Git, базовий TypeScript. Віддалена робота з гнучким графіком.
Middle+ PHP Developer з DevOps-експертизою для мультикоманди, що розробляє CRM та освітню платформу. Основна роль: проєктування й реалізація модулів на PHP 8 і Laravel, оптимізація під високе навантаження, розробка інтеграцій. DevOps-компонент: налаштування CI/CD, контейнеризація (Docker/Kubernetes), управління AWS-інфраструктурою. Вимоги: глибока експертиза Laravel, Docker, Kubernetes, Git, технічна англійська.
Bagisto — це платформа для електронної комерції, побудована на Laravel. Вона надає готове рішення для створення та управління інтернет-магазинами з підтримкою каталогу товарів, замовлень, платежів та клієнтів.