MidТеория4 min

Cookies и кэширование

Set-Cookie, SameSite, Cache-Control, ETag, Last-Modified — HTTP кэширование в Symfony

Управление cookies и HTTP-кэширование — важные темы на экзамене Symfony. На вопросах проверяют знание атрибутов cookie, стратегий кэширования и правильного использования заголовков.

Cookies

Атрибут Описание Значение
Name=Value Имя и значение session=abc123
Domain Домен, для которого cookie действует .example.com
Path Путь на сервере /api
Expires Дата истечения (абсолютная) Thu, 01 Jan 2026...
Max-Age Время жизни в секундах 3600
Secure Только через HTTPS Флаг
HttpOnly Недоступен через JavaScript Флаг
SameSite Защита от CSRF Strict, Lax, None

SameSite Policy

Значение Cross-site GET Cross-site POST Описание
Strict Нет Нет Максимальная защита
Lax Да Нет Баланс (по умолчанию)
None Да Да Без ограничений (требует Secure)

Подвох экзамена: SameSite=None ОБЯЗАТЕЛЬНО требует атрибут Secure (только HTTPS). Без Secure браузер отклонит cookie с SameSite=None. По умолчанию браузеры используют Lax.

Cookies в Symfony

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpFoundation\Response;

// Creating a cookie
$cookie = Cookie::create('preferences')
    ->withValue(json_encode(['theme' => 'dark']))
    ->withExpires(new \DateTimeImmutable('+30 days'))
    ->withPath('/')
    ->withDomain('.example.com')
    ->withSecure(true)
    ->withHttpOnly(true)
    ->withSameSite('lax');

$response = new Response('OK');
$response->headers->setCookie($cookie);

// Shorthand — set raw cookie string
$response->headers->setCookie(
    Cookie::create('tracking', 'abc123', time() + 86400)
);

// Reading cookies from request
$preferences = $request->cookies->get('preferences');

// Deleting a cookie — set with past expiration
$response->headers->clearCookie('preferences', '/', '.example.com');

Подвох экзамена: Cookie::create() в Symfony возвращает объект Cookie (immutable с PHP-совместимыми withers). Метод clearCookie() создаёт cookie с Max-Age=0. Path и Domain должны совпадать с оригинальными при удалении, иначе cookie не удалится.

Expires vs Max-Age

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\Cookie;

// Expires — absolute datetime
$cookie = Cookie::create('session')
    ->withValue('abc')
    ->withExpires(new \DateTimeImmutable('2026-12-31'));

// Max-Age — relative seconds (preferred)
$cookie = Cookie::create('session')
    ->withValue('abc')
    ->withExpires(new \DateTimeImmutable('+1 hour'));

// Session cookie — no Expires/Max-Age (deleted when browser closes)
$cookie = Cookie::create('session')
    ->withValue('abc')
    ->withExpires(0);

HTTP-кэширование

Модели кэширования

HTTP определяет две модели кэширования:

  1. Expiration model — клиент знает, когда кэш устаревает
  2. Validation model — клиент проверяет актуальность у сервера

Cache-Control

Cache-Control: public, max-age=3600, s-maxage=86400
Директива Описание
public Может кэшировать любой прокси
private Только браузер пользователя
no-cache Можно кэшировать, но нужно валидировать каждый раз
no-store Вообще не кэшировать
max-age=N Время жизни в секундах (для клиента)
s-maxage=N Время жизни для shared кэша (прокси, CDN)
must-revalidate После истечения max-age обязательно валидировать
immutable Ресурс никогда не изменится
stale-while-revalidate=N Отдать stale, пока идёт ревалидация

Подвох экзамена: no-cache НЕ означает "не кэшировать"! Это значит "кэшируй, но каждый раз проверяй актуальность у сервера". Для полного запрета кэширования нужен no-store. Это один из самых частых вопросов.

Кэширование в Symfony

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\Response;

class ProductController extends AbstractController
{
    // Expiration model — public cache for 1 hour
    public function list(): Response
    {
        $response = $this->render('product/list.html.twig', [
            'products' => $products,
        ]);

        $response->setPublic();
        $response->setMaxAge(3600);        // Browser: 1 hour
        $response->setSharedMaxAge(86400); // CDN: 24 hours

        return $response;
    }

    // Private cache — user-specific content
    public function dashboard(): Response
    {
        $response = $this->render('dashboard.html.twig');

        $response->setPrivate();
        $response->setMaxAge(60); // Only browser, 1 minute

        return $response;
    }

    // No cache at all
    public function checkout(): Response
    {
        $response = $this->render('checkout.html.twig');

        $response->headers->addCacheControlDirective('no-store');

        return $response;
    }
}

ETag (Entity Tag)

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class ArticleController extends AbstractController
{
    public function show(int $id, Request $request): Response
    {
        $article = $this->repository->find($id);

        // Generate ETag from content hash
        $etag = md5($article->getContent() . $article->getUpdatedAt()->getTimestamp());

        $response = new Response();
        $response->setEtag($etag);
        $response->setPublic();

        // Check If-None-Match header
        if ($response->isNotModified($request)) {
            return $response; // 304 Not Modified — no body sent
        }

        $response->setContent(
            $this->renderView('article/show.html.twig', ['article' => $article])
        );

        return $response;
    }
}

Last-Modified

<?php

declare(strict_types=1);

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

class PageController extends AbstractController
{
    public function show(string $slug, Request $request): Response
    {
        $page = $this->repository->findBySlug($slug);
        $lastModified = $page->getUpdatedAt();

        $response = new Response();
        $response->setLastModified($lastModified);
        $response->setPublic();

        // Check If-Modified-Since header
        if ($response->isNotModified($request)) {
            return $response; // 304 — content not changed
        }

        $response->setContent(
            $this->renderView('page/show.html.twig', ['page' => $page])
        );

        return $response;
    }
}

ETag vs Last-Modified

Критерий ETag Last-Modified
Точность Byte-level (хэш контента) Секундная (дата)
Заголовок запроса If-None-Match If-Modified-Since
Заголовок ответа ETag Last-Modified
Тип Strong / Weak Только дата
Приоритет Выше (RFC 9110) Ниже
Вычисления Нужен хэш Нужна дата

Подвох экзамена: Если в запросе есть оба заголовка (If-None-Match и If-Modified-Since), сервер ДОЛЖЕН проверить оба, но ETag имеет приоритет по RFC 9110. Symfony isNotModified() правильно обрабатывает оба.

HTTP Cache в Symfony (Reverse Proxy)

<?php

declare(strict_types=1);

// config/packages/framework.yaml equivalent
// framework:
//     http_cache:
//         enabled: true

// Symfony includes built-in reverse proxy (HttpCache)
// In public/index.php:
use Symfony\Bundle\FrameworkBundle\HttpCache\HttpCache;

$kernel = new HttpCache($kernel);

Symfony 8.0: В Symfony 8.0 HTTP Cache конфигурация упрощена. Встроенный reverse proxy по-прежнему доступен, но рекомендуется Varnish или CDN для production. Cache-тэги и purge API стали стандартной частью FrameworkBundle.

Vary Header

<?php

declare(strict_types=1);

// Vary tells caches that response differs based on certain headers
$response->setVary(['Accept-Encoding', 'Accept-Language']);

// Example: same URL, different content based on Accept header
// GET /api/products Accept: application/json → JSON response
// GET /api/products Accept: text/html → HTML response
// Cache stores SEPARATE versions for each Accept value

Подвох экзамена: Без заголовка Vary кэш будет отдавать одну версию всем клиентам, даже если они запрашивают разные форматы. Если ответ зависит от Accept, Accept-Language или Cookie — обязательно укажите Vary.