Caching

Learn how to cache your application safely.

Many Symfony applications sit behind a shared cache, such as a CDN, a reverse proxy, Varnish, or the Symfony HTTP cache, to serve pages fast. This guide explains how to keep that performance without serving one visitor’s personalized content to another.

The problem with shared caches

A shared cache stores one copy of a response and serves it to everyone. That is what you want for pages that look the same for all visitors, but it is unsafe for personalized pages. If a visitor’s tailored content were stored in a shared cache, the next visitor would receive it too.

The bundle prevents this automatically. Whenever a response depends on the visitor, because you fetched personalized content, evaluated a query, or identified the user, the bundle marks it private so shared caches skip it. Responses that render only static content stay cacheable.

This relies on your cache honoring the standard Cache-Control: private directive, which CDNs and reverse proxies respect by default. If yours is configured to ignore it or to force caching, exclude personalized responses first.

Choosing an approach

When a page needs personalization, you have two options depending on whether you want to keep it in the shared cache:

Personalize on the serverPersonalize on the client
Final content on first load
No content flicker
Visible to crawlers
Page stored in a shared cache

Personalizing on the server gives the best experience, while personalizing on the client lets the page stay in the shared cache. Choose per page based on what matters most. When only part of a page is personalized, you can cache the rest and personalize that section on the edge.

Personalize on the server

When you fetch content or evaluate a query in a controller, the bundle marks the response private automatically, so it is never stored in a shared cache. The visitor still gets the page quickly from your application, and every request is personalized.

This is the default and requires no extra work. Keep your other pages cacheable by not reading visitor data on them.

Personalize on the client

To keep a page in the shared cache, render it without server-side personalization, so the HTML is the same for everyone, then personalize it in the browser.

For example, render a default hero on the server and replace it on the client once the SDK loads:

templates/home/index.html.twig
123456789101112131415
{% extends 'base.html.twig' %}
{% block body %}    <section id="hero">        <h1>Welcome to Croct!</h1>    </section>
    {% apply croct %}        const heading = document.querySelector('#hero h1');
        croct.fetchContent('home-hero').then(({content}) => {            heading.textContent = content.title;        });    {% endapply %}{% endblock %}

Because the page does not depend on the visitor on the server, it stays cacheable. The trade-off is a brief moment where the default content shows before the personalized content loads.

Personalize on the edge

Most pages are largely the same for every visitor, with only a small section that changes, such as a personalized hero, a recommendation block, or a tailored call to action. Caching the whole page would be unsafe, but giving up caching entirely would be wasteful.

Edge Side Includes let you have both. You cache the static shell of the page and pull the personalized section in as a separate fragment. The shared cache stores and serves the shell, while the fragment is requested on its own for every visit. Because the fragment reads the visitor, the bundle marks it private, so the cache always resolves it fresh and never stores it.

First, enable ESI in your framework configuration:

config/packages/framework.yaml
framework:    esi: true

Then render the personalized section as a sub-request from your cacheable page:

templates/home/index.html.twig
1234567
{% extends 'base.html.twig' %}
{% block body %}    <h1>Welcome to Acme</h1>
    {{ render_esi(controller('App\\Controller\\HeroController::index')) }}{% endblock %}

Finally, fetch the personalized content in the fragment controller:

src/Controller/HeroController.php
123456789101112131415161718192021222324
<?php
namespace App\Controller;
use Croct\Plug\Plug;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\Response;
final class HeroController extends AbstractController{    private Plug $croct;
    public function __construct(Plug $croct)    {        $this->croct = $croct;    }
    public function index(): Response    {        $hero = $this->croct->fetchContent('home-hero')->getContent();
        return $this->render('home/_hero.html.twig', ['hero' => $hero]);    }}

The shell stays in the shared cache and loads instantly, while only the small personalized fragment is computed per request. This requires a gateway cache that supports ESI, such as the Symfony HTTP cache, Varnish, or a CDN with ESI support.