<? phpukraine СТАТТІ
⌕Пошук по платформі
ПРОДУКТИВНІСТЬ 29 вересня 2026 · 8 хв читання

HTTP-кеш для PHP-застосунку: Cache-Control, ETag і CDN перед Laravel

Запит, який не дійшов до php-fpm, коштує нуль. Але щоб віддати відповідь з edge або з браузера, треба правильно проставити Cache-Control, навчитися рахувати ETag до рендеру, а не після, і не покласти сторінку залогіненого користувача у спільний кеш. Розбираємо механіку на Laravel 13 і Cloudflare.

РP
Редакція phpukraine
Редакція платформи

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 обслуговує кожен запит.

ПИШЕТЕ ПРО PHP?Опублікуйте розбір або історію з проєкту на платформіРедактор із чеклістом, редактура, авторська сторінка. Републікація з блогу отримує canonical на оригінал. Відкрити редактор →
РP
Редакція phpukraine
Редакція платформи
Матеріали, які готує команда платформи на основі власних даних: каталогу вакансій, зарплатного звіту й банку питань. Кожна цифра в них рахується з бази, а не береться з голови.
оновлено 3 жовтня 2026 · ліцензія CC-BY-SA-4.0
ДАЛІ ПО ТЕМІ
ЧИТАТИ ДАЛІ
← Усі статті