HTTP Caching

Cache-Control, ETags and conditional requests, and deciding what is genuinely safe to store for everyone.

SyntaxResponse::isNotModified(Request $request): bool

An application cache stores data inside PHP. HTTP caching stores the finished response outside it. A browser, a CDN, or a reverse proxy (a cache placed in front of your application that answers requests it already holds) keeps the bytes and serves them with no PHP running at all. The response itself carries the rules, in its headers.

Expiration

setPublic() says shared caches may store this response. setMaxAge(60) says it stays fresh for sixty seconds. For that minute nothing reaches your code. Use setPublic() with setMaxAge() rather than setSharedMaxAge() alone: the documentation notes that s-maxage forbids a cache serving a stale response in stale-if-error situations, so you lose a useful safety net.

Validation

The other model sends a validator: an ETag, which is an opaque version string, or a Last-Modified date. The client returns it in If-None-Match or If-Modified-Since. Response::isNotModified($request) compares them and, on a match, turns the response into 304 Not Modified with no body. Your code still runs, so you save bandwidth, not CPU, unless you compute the validator before the expensive part.

What is safe for everyone

Public means every visitor, including the next one. Anything shaped by who is asking, or by a role, must stay private, or be split into a public shell and a private fragment. Symfony keeps responses private once the session has been touched, unless you set cache headers yourself; doing so overrides that protection.

Example

Example · php
<?php
// src/Controller/ArticleController.php
declare(strict_types=1);

namespace App\Controller;

use App\Entity\Article;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class ArticleController extends AbstractController
{
    #[Route('/articles/{id}', name: 'app_article_show', methods: ['GET'])]
    public function show(Article $article, Request $request): Response
    {
        $response = new Response();

        // Validators first: both are cheap to work out.
        $response->setLastModified($article->getUpdatedAt());
        $response->setEtag(\sha1($article->getId() . '-' . $article->getUpdatedAt()->format('U')));

        // Storable by shared caches, fresh for one minute.
        $response->setPublic();
        $response->setMaxAge(60);

        // Returns true when the request's validator still matches.
        if ($response->isNotModified($request)) {
            // Already a 304 with no body: rendering is skipped entirely.
            return $response;
        }

        // Third argument reuses the headers set above.
        return $this->render('article/show.html.twig', ['article' => $article], $response);
    }
}

// GET /articles/7 returns 200 with the rendered page and
// Cache-Control: public, max-age=60 plus an ETag.
// The same request with a matching If-None-Match returns 304 and no body.

When to use it

  • Serve a marketing page from a CDN for ten minutes so traffic spikes never reach PHP.
  • Return 304 to a mobile client polling an endpoint that rarely changes.
  • Keep a page public but vary it on Accept-Language for a translated site.

More examples

The attribute and the method calls

#[Cache] is shorthand for setting the headers by hand, and it does not override headers the controller already set. Prefer the attribute for pages whose policy is fixed, and the method calls when the policy depends on the data you just loaded, such as a shorter max-age for a record that changes often.

Example · php
<?php
// src/Controller/PricingController.php
declare(strict_types=1);

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\Cache;
use Symfony\Component\Routing\Attribute\Route;

final class PricingController extends AbstractController
{
    // Shorthand: the attribute sets the same headers.
    #[Route('/pricing', name: 'app_pricing', methods: ['GET'])]
    #[Cache(public: true, maxage: 600, mustRevalidate: true)]
    public function index(): Response
    {
        return $this->render('marketing/pricing.html.twig');
    }

    // Longhand: identical result, written out.
    #[Route('/terms', name: 'app_terms', methods: ['GET'])]
    public function terms(): Response
    {
        $response = $this->render('marketing/terms.html.twig');
        $response->setPublic();
        $response->setMaxAge(600);
        $response->headers->addCacheControlDirective('must-revalidate');

        return $response;
    }
}

// Both routes return 200 with Cache-Control: public, max-age=600, must-revalidate.

Turning on the Symfony reverse proxy

This gives you a working reverse proxy with no extra infrastructure, which is enough to validate your headers before a CDN is involved. It is a PHP process, so it is slower than Varnish or a CDN edge; the value is that the headers you tune here are the same ones those understand. In debug mode Symfony adds an X-Symfony-Cache header showing whether the request was a hit.

Example · yaml
# config/packages/framework.yaml
when@prod:
    framework:
        # The kernel caches its own responses and serves them back.
        http_cache: true

        # Required when a load balancer or CDN sits in front, otherwise
        # the application sees the proxy's address and scheme.
        trusted_proxies: '%env(TRUSTED_PROXIES)%'
        trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']

Varying on a header

Vary tells shared caches that the response depends on a request header, so they keep one copy per value. It is how you cache a translated or negotiated response publicly without serving a French page to an English reader. Keep the list short: every header you vary on multiplies the number of stored copies and lowers the hit rate.

Example · php
<?php
// src/Controller/Api/FeedController.php
declare(strict_types=1);

namespace App\Controller\Api;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class FeedController extends AbstractController
{
    #[Route('/api/feed', name: 'app_api_feed', methods: ['GET'])]
    public function index(): JsonResponse
    {
        $response = new JsonResponse(['items' => []]);

        $response->setPublic();
        $response->setMaxAge(30);

        // One stored copy per distinct Accept-Language value.
        $response->setVary(['Accept-Language']);

        return $response;
    }
}

// GET /api/feed returns 200 with {"items":[]},
// Cache-Control: public, max-age=30 and Vary: Accept-Language.

Discussion

  • Be the first to comment on this lesson.