Laravel за замовчуванням не кешується ніде. Symfony HttpFoundation, на якій стоїть Illuminate\Http\Response, у ResponseHeaderBag::computeCacheControlValue() робить просту річ: якщо ви не виставили Cache-Control, Expires, Last-Modified чи ETag, у відповідь піде Cache-Control: no-cache, private. Тобто кожна сторінка вашого застосунку прямо зараз проходить весь стек для кожного відвідувача, і жоден проксі її не збереже. Поведінка безпечна, і саме через це HTTP-кеш доводиться вмикати руками.
Два кеші з різними правилами
Браузерний кеш приватний: він належить одному відвідувачу, слухається max-age і private, і інвалідувати його ви не можете. Якщо ви віддали HTML з max-age=86400, частина людей бачитиме цю сторінку добу, навіть коли на сервері вже інший контент. Жодного механізму «скинь у всіх» у HTTP немає.
CDN і будь-який reverse proxy тримають спільний кеш: він слухається s-maxage, повністю ігнорує записи з private і no-store, і його можна очистити одним викликом API. З цієї асиметрії випливає базове правило розподілу TTL: великі значення тримайте на edge, у браузер віддавайте маленький max-age або нуль. Тоді сторінку обслуговує CDN без звернення до PHP, а виправлення доїжджає до всіх після purge.
Ключ кешу на edge складається з методу і URL. Все інше треба перевіряти по документації свого CDN, бо реалізації різняться. Cloudflare, наприклад, не враховує Vary ні за яким заголовком, крім Accept-Encoding. Тому схема «розділимо кеш за Vary: Cookie» працює у Varnish і в nginx, але не на Cloudflare. Що там реально відбувається, видно з двох заголовків у відповіді: CF-Cache-Status (HIT, MISS, EXPIRED, BYPASS, DYNAMIC, REVALIDATED) і Age - скільки секунд об'єкт уже лежить у кеші.
Cache-Control для сторінок і статики
Зі статикою все просто, якщо імена файлів містять хеш. Vite складає app-4f2b1c9d.js, і такий файл ніколи не змінює свій вміст: нова збірка дає нове ім'я. Для public/build можна ставити максимальний TTL і immutable, який забороняє браузеру навіть умовний запит на перевірку:
Cache-Control: public, max-age=31536000, immutable
Виставляється це у веб-сервері (location ^~ /build/ у nginx), а не в PHP. Все, що PHP віддає з диска через Storage::download() або BinaryFileResponse, треба помічати окремо: там імена не хешовані, і року TTL давати не можна.
Для HTML, який генерує Laravel, є middleware cache.headers (Illuminate\Http\Middleware\SetCacheHeaders). Він приймає опції рядком:
Route::get('/jobs', JobsController::class)
->middleware('cache.headers:public;max_age=0;s_maxage=600;etag');
max_age=0 означає, що браузер завжди перепитує, s_maxage=600 дає edge десять хвилин автономії. Middleware розбирає рядок і передає масив у Response::setCache(), тому працюють тільки ті ключі, які знає Symfony: public, private, max_age, s_maxage, etag, last_modified, must_revalidate, proxy_revalidate, no_cache, no_store, no_transform, immutable, stale_while_revalidate, stale_if_error. Ключ із помилкою не ігнорується: setCache() кидає InvalidArgumentException з переліком невідомих опцій, і ви отримаєте 500 на проді, а не тихо відсутній заголовок.
Три директиви плутають частіше за інші. no-store забороняє зберігати відповідь будь-де, і це єдине, що годиться для сторінок з персональними даними. no-cache дозволяє зберігати, але вимагає перевірки перед кожним використанням; назва обманює, бо йдеться про обов'язковий умовний запит, а не про заборону кешувати. must-revalidate забороняє віддавати протухле значення, коли origin недоступний.
stale-while-revalidate і stale-if-error виглядають привабливо: перша дозволяє віддати старе значення миттєво і оновити його у фоні, друга - показати старе замість 502. Перед тим як будувати на них SLA, перевірте, чи їх реалізує саме ваш CDN і ваш браузер, бо покриття нерівне. Як приємне доповнення вони годяться, як основний механізм доступності - ні.
ETag і 304
Стандартний рецепт, який дає cache.headers:etag, такий: відрендерити відповідь, узяти md5() від тіла, порівняти з If-None-Match і, якщо збіглося, відповісти 304. Економія тут суто в трафіку. PHP уже виконав усі запити до бази, зібрав Blade і витратив ту саму пам'ять; ви лише не відправили байти. Для великої JSON-відповіді це має сенс, для дешевої сторінки - майже ні.
Значно більше дає валідатор, порахований до рендеру, з чогось, що дістається одним легким запитом:
public function show(Request $request, string $slug): Response
{
$article = Article::query()
->where('slug', $slug)
->firstOrFail(['id', 'slug', 'updated_at']);
$response = new Response();
$response->setEtag(sha1(implode('|', [
config('app.release'),
$article->id,
$article->updated_at->getTimestamp(),
])), weak: false);
$response->setCache(['public' => true, 'max_age' => 0, 's_maxage' => 300]);
if ($response->isNotModified($request)) {
return $response;
}
return $response->setContent(
view('articles.show', ['article' => $article])->render(),
);
}
isNotModified() сам порівнює If-None-Match і If-Modified-Since, а при збігу викликає setNotModified(): ставить статус 304 і прибирає Content-Type, Content-Length, Content-Encoding, Last-Modified. Тіло не рендериться взагалі, і найдорожча частина запиту не виконується. config('app.release') у сіді - це git sha з .env, до нього повернемось у розділі про деплой.
Один нюанс про weak: false. Сильний ETag обіцяє побайтову ідентичність, а CDN може змінити тіло: увімкнути компресію, зробити мініфікацію. Cloudflare у таких випадках переписує сильний ETag у слабкий (W/"..."), і це штатна поведінка, а не поламаний заголовок. Якщо ви не покладаєтесь на Range-запити, різниці для вас немає.
У Last-Modified гранулярність в одну секунду: два збереження в межах секунди зіллються в одне, і клієнт побачить старе. ETag від updated_at плюс версія релізу цю проблему не має, тож для контенту, який правлять часто, беріть ETag.
Анонімні й залогінені на одному URL
Найдорожче обходиться public на відповіді, яка містить дані конкретного користувача. Спільний кеш віддасть її наступному відвідувачу, і замість тюнінгу продуктивності ви отримаєте інцидент з персональними даними. Тому рішення, кешувати чи ні, має бути явним і покритим тестом.
Кешуванню анонімного HTML у Laravel механічно заважає сесія. StartSession у групі web відкриває її навіть гостю, тому в кожній відповіді летить Set-Cookie з laravel_session і XSRF-TOKEN. Cloudflare за замовчуванням вважає відповідь із Set-Cookie некешованою; правилом це можна перевизначити, але тоді ви ризикуєте віддати чужий cookie іншому відвідувачу, тому не варто.
Друга перешкода складніша: CSRF-токен усередині HTML. Якщо публічно закешувати сторінку з формою, всі отримають один токен з чужої сесії і форма відповість 419. Публічно кешувати виходить тільки сторінки без форм, а для решти токен беруть окремим запитом до некешованого endpoint і підставляють на клієнті.
Робоча схема: окрема група роутів для гостьового контенту без StartSession, плюс middleware, який фіксує рішення:
final class CacheForGuests
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($request->user() !== null || ! $request->isMethodCacheable()) {
return $response->header('Cache-Control', 'private, no-store');
}
$response->setCache(['public' => true, 'max_age' => 0, 's_maxage' => 600]);
$response->headers->remove('Set-Cookie');
return $response;
}
}
Set-Cookie тут видаляється свідомо: без цього edge просто не візьме відповідь у кеш. Вішати такий middleware можна лише там, де сесія не потрібна, інакше ви зламаєте логін.
Лишається пастка виконання: якщо на цьому URL хоч раз побував залогінений користувач раніше за гостя, він міг покласти в кеш свою версію (за умови, що CDN узагалі це дозволив). Тому на edge поверх цього потрібне правило bypass за наявністю cookie сесії, наприклад Cache Rule з умовою на http.cookie і дією «Bypass cache». Разом виходить два незалежні бар'єри: PHP не позначає персональні відповіді як public, а CDN не заглядає в кеш, коли бачить cookie.
І тест, який ловить регресію раніше за користувачів:
it('never marks an authenticated page as publicly cacheable', function (): void {
$this->actingAs(User::factory()->create())
->get('/jobs')
->assertHeader('Cache-Control', 'private, no-store');
});
Інвалідація після деплою
Статика інвалідується сама: новий білд дає нові імена файлів, старі поступово випадають за TTL. Єдина умова: розкласти нові ассети на диск до того, як HTML почне на них посилатися. При atomic deploy через symlink це виходить автоматично, при git pull на живому каталозі - ні, і користувач між двома моментами отримає 404 на js.
З HTML складніше, бо URL не змінюється. Тут два кроки, і порядок важливий. Спершу новий код стає активним, і тільки потім ви чистите edge. Якщо зробити навпаки, CDN сходить на origin зі старим кодом і наповнить кеш заново тим самим старим HTML, після чого purge треба повторювати.
final class PurgeCdnCache extends Command
{
protected $signature = 'cdn:purge {url?*}';
public function handle(Http $http): int
{
$payload = $this->argument('url') === []
? ['purge_everything' => true]
: ['files' => $this->argument('url')];
$response = Http::withToken(config('services.cloudflare.token'))
->post(sprintf(
'https://api.cloudflare.com/client/v4/zones/%s/purge_cache',
config('services.cloudflare.zone'),
), $payload);
return $response->successful() ? self::SUCCESS : self::FAILURE;
}
}
Точковий purge за списком URL обмежений: на планах, нижчих за Enterprise, це 30 адрес за виклик, а purge за cache-tag і за префіксом взагалі доступний лише на Enterprise. Проєктувати інвалідацію під теги, яких у вас немає, не варто; дешевше тримати s-maxage у межах кількох хвилин для сторінок, що змінюються, і залишити purge_everything для аварійних правок.
Окремо про ETag і релізи. Якщо валідатор порахований тільки від updated_at, то після деплою, який змінив шаблон, браузери з If-None-Match продовжать отримувати 304 і старий HTML з власного кешу. Саме для цього в сід ETag додається версія релізу: змінився git sha - змінилися всі ETag, і перший умовний запит повертає 200 з новим тілом.
Перевірити, що у вас зараз відбувається, можна за півхвилини: curl -sI https://example.test/jobs і подивитися на три речі. Чи є Cache-Control, крім дефолтного no-cache, private. Чи летить Set-Cookie на сторінці, яку ви вважаєте публічною. Чи змінюється CF-Cache-Status з MISS на HIT при повторному запиті. Якщо HIT не з'являється ніколи, то CDN у вас працює лише як TLS-термінатор, а PHP обслуговує кожен запит.