HTTP Cache mit dem Symfony Reverse Proxy: ESI ohne Varnish
AI generated
SF
{ }
Symfony · HTTP Cache · Reverse Proxy · ESI
HTTP Cache mit dem Symfony Reverse Proxy
ESI-Fragmente ohne externen Varnish

Symfony bringt mit der HttpCache-Klasse einen vollwertigen HTTP Reverse Proxy direkt im PHP-Prozess mit, inklusive Unterstützung für Edge Side Includes. Wer diesen Reverse Proxy richtig konfiguriert, cached ganze Seiten und einzelne Fragmente, ohne zusätzliche Infrastruktur wie Varnish betreiben zu müssen.

20 Min. Lesezeit HttpCache · ESI · Surrogate-Control · s-maxage Symfony 7.x

1. Was der Symfony Reverse Proxy wirklich ist

Der Symfony Reverse Proxy ist keine externe Software, sondern eine PHP-Klasse namens HttpCache, die den Kernel umschließt und eingehende Anfragen anhand von HTTP-Cache-Headern beantwortet, bevor die eigentliche Anwendungslogik überhaupt läuft. Das Konzept dahinter folgt exakt der HTTP-Spezifikation: Antworten mit Cache-Control: public und einem gültigen max-age oder s-maxage werden im Reverse Proxy zwischengespeichert und bei identischen nachfolgenden Anfragen direkt ausgeliefert, ohne dass der Symfony-Kernel, die Datenbank oder irgendein Controller involviert wird.

Der entscheidende Vorteil dieses Ansatzes gegenüber einem externen Reverse Proxy wie Varnish liegt in der Einfachheit der Infrastruktur: Es gibt keinen zusätzlichen Dienst, keine separate Konfigurationssprache wie VCL und keine zusätzliche Netzwerk-Hop-Latenz. Für kleinere bis mittelgroße Symfony-Anwendungen, die keine extremen Traffic-Spitzen bedienen müssen, deckt der eingebaute Reverse Proxy einen erheblichen Teil der Caching-Anforderungen ab, ohne dass ein Ops-Team eine zusätzliche Komponente betreiben und überwachen muss.

2. HttpCache im Kernel aktivieren

Um den Symfony Reverse Proxy zu aktivieren, wird die Klasse HttpCache um den Kernel in public/index.php gelegt. Damit fängt HttpCache jede eingehende Anfrage zuerst ab, prüft den eigenen Store auf einen gültigen Cache-Eintrag, und leitet die Anfrage nur bei einem Cache-Miss an den echten Symfony-Kernel weiter. Das Ergebnis wird dann anhand der zurückgegebenen Cache-Header gespeichert, sofern die Antwort als cachebar markiert wurde.

Wichtig für den produktiven Einsatz: Der Default-Store von HttpCache speichert im Dateisystem unter var/cache/prod/http_cache. Bei mehreren PHP-FPM-Workern auf demselben Server ist das unproblematisch, weil sie sich dasselbe Dateisystem teilen. Bei mehreren Servern hinter einem Load Balancer braucht jeder Server einen eigenen, unabhängigen HTTP-Cache, was zu inkonsistenten Trefferquoten führen kann, wenn Nutzer zwischen Servern wechseln.


<?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. Cache-Header steuern: public, private, s-maxage

Damit der Reverse Proxy überhaupt etwas cachen kann, muss jeder Controller explizit angeben, ob und wie lange eine Antwort cachebar ist. Die Response-Methode setPublic() markiert eine Antwort als für alle Nutzer identisch cachebar, während setPrivate() das Gegenteil signalisiert und typischerweise für personalisierte Inhalte genutzt wird. Der Unterschied zwischen max-age und s-maxage ist entscheidend: max-age gilt für Browser-Caches, s-maxage speziell für gemeinsam genutzte Caches wie den Symfony Reverse Proxy oder CDNs.

Eine bewährte Strategie in der Praxis: Produktseiten eines Shops mit s-maxage von wenigen Minuten cachen, während der Browser selbst über ein deutlich kürzeres oder gar kein max-age verfügt. So bleibt der Reverse Proxy die Quelle der Wahrheit für Aktualität, während Endnutzer nie veraltete Inhalte aus ihrem eigenen Browser-Cache sehen.


<?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-Fragmente für teilweise dynamische Seiten

Der größte praktische Vorteil des Symfony Reverse Proxy gegenüber einfachem Full-Page-Caching ist die native Unterstützung für Edge Side Includes. Viele Seiten sind größtenteils statisch, enthalten aber einzelne personalisierte Elemente, etwa einen Warenkorb-Zähler im Header. Statt die gesamte Seite nicht cachebar zu machen, rendert man den dynamischen Teil als separates ESI-Fragment mit eigenem Cache-Verhalten, während der Rest der Seite langfristig gecacht bleibt.

Damit ESI funktioniert, muss Esi::enable() im Kernel aktiviert und im Template mit render_esi() statt einem regulären render()-Aufruf gearbeitet werden. Der Reverse Proxy ersetzt beim Ausliefern die ESI-Tags durch das jeweils aktuelle Fragment und cached Hauptseite und Fragment vollständig unabhängig voneinander.


<?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 und gezielte Invalidierung

Ein oft unterschätztes Problem bei jedem Reverse Proxy: Wenn sich ein Produkt ändert, muss die gecachte Seite invalidiert werden, bevor der nächste Nutzer veraltete Daten sieht. Symfonys HttpCache unterstützt dafür das Interface PurgeableInterface, mit dem sich einzelne URLs gezielt aus dem Cache entfernen lassen, sobald ein relevantes Ereignis eintritt, etwa ein Doctrine-Postupdate-Event nach einer Preisänderung.

Für komplexere Abhängigkeiten, bei denen eine Kategorieseite von mehreren Produkten abhängt, reicht eine einzelne URL-Invalidierung oft nicht aus. Hier kommen Surrogate-Keys ins Spiel: Jede Antwort bekommt einen benutzerdefinierten Header mit den IDs aller enthaltenen Entities, und ein Event-Listener sammelt beim Speichern einer Entity alle betroffenen Surrogate-Keys, um gezielt genau die richtigen Cache-Einträge zu invalidieren, ohne den kompletten Cache leeren zu müssen.

6. Validierungscache mit ETag und Last-Modified

Nicht jede Antwort lässt sich mit einem festen max-age cachen, etwa wenn Inhalte sich unregelmäßig ändern und die Aktualität wichtiger ist als eine feste Zeitspanne. Für diesen Fall unterstützt der Symfony Reverse Proxy den Validierungscache über ETag und Last-Modified. Der Controller berechnet einen Hash oder ein Änderungsdatum, und Symfony prüft automatisch, ob der Client bereits eine aktuelle Version besitzt, bevor der komplette Antwortkörper überhaupt gerendert wird.

Der entscheidende Performance-Vorteil: Bei einem 304 Not Modified wird die teure Template-Rendering-Logik komplett übersprungen. Symfonys Response::isNotModified()-Methode übernimmt den Vergleich mit den Request-Headern If-None-Match und If-Modified-Since automatisch, sodass Entwickler nur die Berechnung des ETags selbst implementieren müssen, meist aus einem Hash der relevanten Datenbank-Timestamps.

7. HttpCache in Produktion: Grenzen und Alternativen

So nützlich der eingebaute Reverse Proxy ist, er hat klare Grenzen. Da HttpCache im selben PHP-Prozess läuft wie die Anwendung selbst, teilt er sich Speicher und CPU mit dem eigentlichen Symfony-Kernel, was bei sehr hohem Traffic zum Flaschenhals werden kann. Ein externer Reverse Proxy wie Varnish oder ein CDN wie Cloudflare kann Anfragen beantworten, ohne den PHP-FPM-Worker überhaupt zu belasten, was bei extremen Lastspitzen einen entscheidenden Unterschied macht.

In der Praxis eignet sich der Symfony Reverse Proxy hervorragend für mittelgroße Anwendungen, interne Tools und Projekte, bei denen der Aufwand für zusätzliche Infrastruktur den Nutzen nicht rechtfertigt. Für Enterprise-Shops mit sehr hohem Traffic bleibt die Kombination aus CDN, externem Reverse Proxy und Symfony als Origin-Server die robustere Wahl, wobei die hier beschriebenen Cache-Header trotzdem die Grundlage bilden, auf der auch externe Caches aufbauen.

8. Trefferquote messen und Cache-Verhalten debuggen

Um zu verstehen, ob der Reverse Proxy tatsächlich greift, aktiviert man in der Staging-Umgebung trace_level: full, wodurch Symfony den Header X-Symfony-Cache in jede Antwort schreibt. Dieser Header zeigt exakt, ob eine Anfrage ein Cache-Hit, ein Cache-Miss oder eine Invalidierung war, und für ESI-Fragmente sogar, welches Fragment welchen Status hatte.

In Produktion sollte trace_level auf none stehen, um keine internen Cache-Details preiszugeben, aber während der Entwicklung und in Lasttests ist dieser Header unverzichtbar, um die tatsächliche Trefferquote zu validieren, bevor man sich auf die Performance-Verbesserung durch den Reverse Proxy verlässt.


# 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 Optionen im direkten Vergleich

Die Entscheidung zwischen dem eingebauten Symfony Reverse Proxy, einem externen Varnish und einem CDN hängt stark von Traffic-Profil und Infrastruktur-Budget ab. Die folgende Tabelle stellt die drei realistischen Optionen gegenüber.

Option Infrastruktur-Aufwand Entlastet PHP-FPM Empfehlung
Symfony HttpCache Keine zusätzliche Infrastruktur Nein, läuft im selben Prozess Kleine bis mittlere Anwendungen
Varnish Zusätzlicher Dienst, VCL-Konfiguration Ja, vollständig Mittlere bis große Anwendungen
CDN (Cloudflare, Fastly) Externer Dienst, DNS-Umstellung Ja, vollständig, plus Edge-Nähe Hoher globaler Traffic
CDN + HttpCache kombiniert CDN als erste Schicht, HttpCache als Fallback Ja, mit granularer ESI-Logik dahinter Enterprise-Setups mit ESI-Bedarf

Ein pragmatischer Mittelweg für wachsende Symfony-Projekte: mit dem eingebauten Reverse Proxy starten, Cache-Header und ESI-Struktur von Anfang an sauber implementieren, und bei Bedarf später ein CDN oder Varnish vorschalten. Die Cache-Header-Logik in den Controllern bleibt dabei unverändert, weil beide Systeme dieselbe HTTP-Cache-Spezifikation respektieren.

Mironsoft

Symfony-Caching-Strategien, HTTP-Performance und CDN-Integration

Jede Anfrage rendert die Seite komplett neu?

Wir richten den Symfony Reverse Proxy produktionsreif ein, implementieren ESI-Fragmente für personalisierte Bereiche und legen bei Bedarf ein CDN sauber davor, für messbar schnellere Antwortzeiten.

Cache-Strategie

Analyse cachebarer Routen und passende Cache-Header pro Endpunkt

ESI-Integration

Personalisierte Fragmente sauber aus dem Full-Page-Cache lösen

Invalidierung

Surrogate-Keys und Event-basierte Cache-Invalidierung implementieren

10. Zusammenfassung

Der Symfony Reverse Proxy in Form der HttpCache-Klasse liefert einen vollwertigen HTTP-Cache direkt im PHP-Prozess, ohne zusätzliche Infrastruktur zu benötigen. Saubere Cache-Header mit setPublic(), setSharedMaxAge() und dem Unterschied zwischen max-age und s-maxage bilden das Fundament. ESI-Fragmente lösen das klassische Problem teilweise dynamischer Seiten, indem personalisierte Bereiche unabhängig vom Rest der Seite gecacht werden.

Surrogate-Keys und Event-basierte Invalidierung sorgen dafür, dass gecachte Inhalte aktuell bleiben, ohne den gesamten Cache bei jeder Änderung zu leeren. Für kleine bis mittlere Symfony-Anwendungen deckt der eingebaute Reverse Proxy die meisten Caching-Anforderungen vollständig ab, während sehr traffic-intensive Projekte zusätzlich ein CDN oder Varnish vorschalten sollten, ohne dabei die grundlegende Cache-Header-Logik in den Controllern zu ändern.

Symfony HTTP Cache Reverse Proxy — Das Wichtigste auf einen Blick

Aktivierung

HttpCache um den Kernel in public/index.php legen, nur in der Produktionsumgebung.

Cache-Header

setPublic() plus setSharedMaxAge() für gemeinsam nutzbare Antworten, setPrivate() für personalisierte Inhalte.

ESI-Fragmente

render_esi() für dynamische Teile, während der Rest der Seite langfristig gecacht bleibt.

Grenzen

Läuft im selben PHP-Prozess wie die Anwendung, bei Extremtraffic zusätzlich CDN oder Varnish erwägen.

11. FAQ: Symfony HTTP Cache Reverse Proxy

1Brauche ich Varnish überhaupt?
Nein, HttpCache reicht für kleine bis mittlere Anwendungen völlig aus und braucht keine zusätzliche Infrastruktur.
2Unterschied max-age vs. s-maxage?
max-age gilt für Browser, s-maxage speziell für gemeinsame Caches wie den Reverse Proxy oder ein CDN.
3Was sind ESI-Fragmente?
Erlauben separates Rendering und Caching dynamischer Seitenteile, ohne die gesamte Seite nicht cachebar zu machen.
4Wie invalidiere ich Inhalte?
Über PurgeableInterface für einzelne URLs oder Surrogate-Keys für mehrere betroffene URLs gleichzeitig.
5Funktioniert das bei mehreren Servern?
Nur eingeschränkt, weil der Default-Store lokal speichert. Für mehrere Server ist ein externer Reverse Proxy robuster.
6Was bringt ETag gegenüber max-age?
Prüft Aktualität bei jeder Anfrage und spart bei 304 Not Modified den kompletten Response-Body.
7Wie sehe ich Cache-Hits?
Mit trace_level: full über den Header X-Symfony-Cache, in Produktion sollte er deaktiviert sein.
8Wann doch Varnish einsetzen?
Sobald der PHP-Prozess selbst zum Flaschenhals wird, meist bei sehr hohem Traffic.
9Private Inhalte separat behandeln?
Ja, mit setPrivate() markieren, sonst könnten personalisierte Inhalte an andere Nutzer ausgeliefert werden.
10HttpCache und CDN kombinieren?
Ja, gängige Architektur: CDN als globale erste Schicht, HttpCache dahinter für granulare ESI-Logik.