HTTP Caching with the Symfony Reverse Proxy: ESI Without Varnish
AI generated
SF
{ }
Symfony · HTTP Cache · Reverse Proxy · ESI
HTTP Caching with the Symfony Reverse Proxy
ESI fragments without external Varnish

Symfony ships a fully fledged HTTP reverse proxy right inside the PHP process through its HttpCache class, including support for edge side includes. Anyone who configures this reverse proxy correctly caches whole pages and individual fragments, without having to run extra infrastructure like Varnish.

20 min read HttpCache · ESI · Surrogate-Control · s-maxage Symfony 7.x

1. What the Symfony reverse proxy really is

The Symfony reverse proxy is not external software but a PHP class called HttpCache that wraps the kernel and answers incoming requests based on HTTP cache headers before the actual application logic even runs. The concept behind it follows the HTTP specification exactly: responses with Cache-Control: public and a valid max-age or s-maxage are stored in the reverse proxy and delivered directly for identical subsequent requests, without the Symfony kernel, the database or any controller being involved at all.

The decisive advantage of this approach over an external reverse proxy like Varnish lies in the simplicity of the infrastructure: there is no additional service, no separate configuration language like VCL, and no extra network hop latency. For small to medium sized Symfony applications that do not need to handle extreme traffic spikes, the built-in reverse proxy covers a significant share of caching requirements, without any ops team having to run and monitor an additional component.

2. Enabling HttpCache in the kernel

To enable the Symfony reverse proxy, the HttpCache class is wrapped around the kernel in public/index.php. This way HttpCache intercepts every incoming request first, checks its own store for a valid cache entry, and only forwards the request to the real Symfony kernel on a cache miss. The result is then stored based on the returned cache headers, provided the response was marked as cacheable.

Important for production use: the default store of HttpCache stores on the file system under var/cache/prod/http_cache. With multiple PHP-FPM workers on the same server this is unproblematic, because they share the same file system. With multiple servers behind a load balancer, each server needs its own independent HTTP cache, which can lead to inconsistent hit rates when users switch between servers.


<?php
// public/index.php — wrap the kernel with Symfony's built-in reverse proxy
declare(strict_types=1);

use App\Kernel;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\HttpCache\HttpCache;
use Symfony\Component\HttpKernel\HttpCache\Store;

require_once dirname(__DIR__) . '/vendor/autoload_runtime.php';

return function (array $context) {
    $kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);

    // Only enable the reverse proxy in prod — dev needs uncached responses
    if ($context['APP_ENV'] === 'prod') {
        $store = new Store(dirname(__DIR__) . '/var/cache/prod/http_cache');
        $kernel = new HttpCache($kernel, $store, null, [
            'default_ttl' => 0,       // never guess a TTL — require explicit headers
            'trace_level' => 'none',  // omit X-Symfony-Cache header in production
        ]);
    }

    return $kernel;
};

3. Controlling cache headers: public, private, s-maxage

For the reverse proxy to cache anything at all, every controller must explicitly state whether and for how long a response is cacheable. The response method setPublic() marks a response as cacheable identically for all users, while setPrivate() signals the opposite and is typically used for personalized content. The difference between max-age and s-maxage is decisive: max-age applies to browser caches, s-maxage specifically to shared caches such as the Symfony reverse proxy or CDNs.

A proven strategy in practice: cache a shop's product pages with an s-maxage of a few minutes, while the browser itself has a much shorter or no max-age at all. This keeps the reverse proxy as the source of truth for freshness, while end users never see stale content from their own browser cache.


<?php
// src/Controller/ProductController.php
declare(strict_types=1);

namespace App\Controller;

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

final class ProductController extends AbstractController
{
    #[Route('/product/{slug}', name: 'product_show')]
    public function show(string $slug): Response
    {
        $product = $this->productRepository->findBySlug($slug);
        $response = $this->render('product/show.html.twig', ['product' => $product]);

        // Cache in the shared reverse proxy for 5 minutes,
        // but let the browser revalidate on every visit.
        $response->setPublic();
        $response->setSharedMaxAge(300);
        $response->headers->addCacheControlDirective('must-revalidate', true);

        return $response;
    }
}

4. ESI fragments for partially dynamic pages

The biggest practical advantage of the Symfony reverse proxy over simple full page caching is the native support for edge side includes. Many pages are mostly static but contain a few personalized elements, such as a cart item counter in the header. Instead of making the whole page non-cacheable, you render the dynamic part as a separate ESI fragment with its own cache behavior, while the rest of the page stays cached long term.

For ESI to work, Esi::enable() has to be activated in the kernel and the template has to use render_esi() instead of a regular render() call. The reverse proxy replaces the ESI tags with the current fragment upon delivery and caches the main page and the fragment completely independently of each other.


<?php
// src/Controller/CartWidgetController.php
declare(strict_types=1);

namespace App\Controller;

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

final class CartWidgetController extends AbstractController
{
    #[Route('/_fragment/cart-widget', name: 'cart_widget')]
    public function widget(): Response
    {
        $response = $this->render('fragment/cart_widget.html.twig', [
            'itemCount' => $this->cartService->getItemCount(),
        ]);

        // Short-lived cache — the fragment itself changes per session,
        // so this is deliberately private with a tiny shared TTL.
        $response->setPrivate();
        $response->setMaxAge(0);

        return $response;
    }
}

5. Surrogate keys and targeted invalidation

An often underestimated problem with every reverse proxy: when a product changes, the cached page must be invalidated before the next user sees stale data. Symfony's HttpCache supports the PurgeableInterface for this, which lets you remove individual URLs from the cache in a targeted way as soon as a relevant event occurs, such as a Doctrine post update event after a price change.

For more complex dependencies, where a category page depends on multiple products, a single URL invalidation is often not enough. This is where surrogate keys come in: every response gets a custom header with the IDs of all included entities, and an event listener collects every affected surrogate key when an entity is saved, so it can invalidate exactly the right cache entries without having to clear the entire cache.

6. Validation caching with ETag and Last-Modified

Not every response can be cached with a fixed max-age, for example when content changes irregularly and freshness matters more than a fixed time span. For this case, the Symfony reverse proxy supports validation caching via ETag and Last-Modified. The controller computes a hash or a modification date, and Symfony automatically checks whether the client already has a current version before the full response body is even rendered.

The decisive performance advantage: on a 304 Not Modified, the expensive template rendering logic is completely skipped. Symfony's Response::isNotModified() method automatically handles the comparison with the request headers If-None-Match and If-Modified-Since, so developers only need to implement the calculation of the ETag itself, usually from a hash of the relevant database timestamps.

7. HttpCache in production: limits and alternatives

As useful as the built-in reverse proxy is, it has clear limits. Because HttpCache runs in the same PHP process as the application itself, it shares memory and CPU with the actual Symfony kernel, which can become a bottleneck under very high traffic. An external reverse proxy like Varnish or a CDN like Cloudflare can answer requests without ever burdening the PHP-FPM worker at all, which makes a decisive difference during extreme load spikes.

In practice, the Symfony reverse proxy is excellent for medium sized applications, internal tools and projects where the effort for extra infrastructure would not justify the benefit. For enterprise shops with very high traffic, the combination of a CDN, an external reverse proxy and Symfony as the origin server remains the more robust choice, though the cache headers described here still form the foundation that external caches build on as well.

8. Measuring hit rate and debugging cache behavior

To understand whether the reverse proxy actually kicks in, you enable trace_level: full in the staging environment, which makes Symfony write the X-Symfony-Cache header into every response. This header shows exactly whether a request was a cache hit, a cache miss or an invalidation, and for ESI fragments even which fragment had which status.

In production, trace_level should be set to none to avoid exposing internal cache details, but during development and load testing this header is indispensable for validating the actual hit rate before relying on the performance improvement from the reverse proxy.


# Inspect the reverse proxy behavior with curl in staging
curl -sI https://staging.example.com/product/example-slug | grep -i x-symfony-cache
# X-Symfony-Cache: GET /product/example-slug: miss, store

curl -sI https://staging.example.com/product/example-slug | grep -i x-symfony-cache
# X-Symfony-Cache: GET /product/example-slug: fresh

9. Reverse proxy options in direct comparison

The decision between the built-in Symfony reverse proxy, an external Varnish, and a CDN depends heavily on traffic profile and infrastructure budget. The following table compares the three realistic options.

Option Infrastructure effort Relieves PHP-FPM Recommendation
Symfony HttpCache No additional infrastructure No, runs in the same process Small to medium applications
Varnish Additional service, VCL configuration Yes, fully Medium to large applications
CDN (Cloudflare, Fastly) External service, DNS cutover Yes, fully, plus edge proximity High global traffic
CDN + HttpCache combined CDN as first layer, HttpCache as fallback Yes, with granular ESI logic behind it Enterprise setups needing ESI

A pragmatic middle ground for growing Symfony projects: start with the built-in reverse proxy, implement cache headers and ESI structure cleanly from the start, and add a CDN or Varnish in front of it later if needed. The cache header logic in the controllers stays unchanged, because both systems respect the same HTTP cache specification.

Mironsoft

Symfony caching strategies, HTTP performance and CDN integration

Every request renders the whole page from scratch?

We set up the Symfony reverse proxy ready for production, implement ESI fragments for personalized areas, and add a CDN cleanly in front if needed, for measurably faster response times.

Cache strategy

Analysis of cacheable routes and matching cache headers per endpoint

ESI integration

Cleanly detach personalized fragments from the full page cache

Invalidation

Implement surrogate keys and event based cache invalidation

10. Summary

The Symfony reverse proxy in the form of the HttpCache class delivers a fully fledged HTTP cache right inside the PHP process, without needing extra infrastructure. Clean cache headers with setPublic(), setSharedMaxAge(), and the distinction between max-age and s-maxage form the foundation. ESI fragments solve the classic problem of partially dynamic pages by caching personalized areas independently from the rest of the page.

Surrogate keys and event based invalidation ensure cached content stays current without clearing the entire cache on every change. For small to medium Symfony applications, the built-in reverse proxy fully covers most caching requirements, while very traffic intensive projects should additionally place a CDN or Varnish in front, without having to change the fundamental cache header logic in the controllers.

Symfony HTTP Cache Reverse Proxy — the essentials at a glance

Activation

Wrap HttpCache around the kernel in public/index.php, only in the production environment.

Cache headers

setPublic() plus setSharedMaxAge() for shareable responses, setPrivate() for personalized content.

ESI fragments

render_esi() for dynamic parts, while the rest of the page stays cached long term.

Limits

Runs in the same PHP process as the application, consider a CDN or Varnish additionally under extreme traffic.

11. FAQ: Symfony HTTP Cache Reverse Proxy

1Do I need Varnish at all?
No, HttpCache is fully sufficient for small to medium applications and needs no extra infrastructure.
2max-age vs. s-maxage difference?
max-age applies to browsers, s-maxage specifically to shared caches like the reverse proxy or a CDN.
3What are ESI fragments?
Allow separate rendering and caching of dynamic page parts, without making the whole page non-cacheable.
4How do I invalidate content?
Via PurgeableInterface for single URLs, or surrogate keys for multiple affected URLs at once.
5Does this work with multiple servers?
Only limited, because the default store saves locally. For multiple servers, an external reverse proxy is more robust.
6What does ETag get me over max-age?
Checks freshness on every request and saves the entire response body on a 304 Not Modified.
7How do I see cache hits?
With trace_level: full via the X-Symfony-Cache header, in production it should be disabled.
8When to use Varnish instead?
As soon as the PHP process itself becomes the bottleneck, usually under very high traffic.
9Handle private content separately?
Yes, mark it with setPrivate(), otherwise personalized content could be served to other users.
10Combine HttpCache and a CDN?
Yes, common architecture: CDN as the global first layer, HttpCache behind it for granular ESI logic.