HTTP Cache
OpenDXP implements tag-based HTTP cache invalidation on top of FOSHttpCacheBundle. FOSHttpCacheBundle writes a cache tags header to every cacheable response, listing all content dependencies. When content changes, only the affected cached responses are invalidated, no full cache flush required.
The response header name (X-Cache-Tags by default) is determined by the configured FOSHttpCacheBundle proxy client.
Varnish purgekeys mode uses xkey; Fastly uses Surrogate-Key. See the FOSHttpCacheBundle proxy client docs for details.
Overview
- How it works
- Setup
- Configuration reference
- Project-specific tags
- Skipping invalidation
- Filtering tag collection
- Disabling tag collection
- ESI fragments
- Symfony Web Profiler
- Proxy configuration
- Symfony HttpCache Example
How it works
1. Tag collection during request
When a page is rendered, OpenDXP automatically collects cache tags for every element that was loaded. Tags are added via registered HttpCacheTagStrategyInterface services.
Built-in tags collected automatically:
| Source | Tags added |
|---|---|
Route document (e.g. /about-us) | document_42 |
| Route DataObject (URL slug) | data_object_17, data_object_class_employee * |
| Document loaded during rendering | document_5, document_12 |
| DataObject loaded during rendering | data_object_3, data_object_8 |
| Asset loaded during rendering | asset_22 |
| Document listing executed | document_list * |
| Asset listing executed | asset_list * |
| DataObject listing executed | data_object_class_employee * |
| Translation changed ** | translation |
WebsiteSetting::getById() called | website_setting_5 |
opendxp_website_config('key') called | website_setting_5 |
opendxp_website_config() called | website_setting_list |
* Only when tag_list: true is set (default) in the elements config.
** Translations in the admin domain are never tagged or invalidated, since they are not part of a cached frontend response.
[!NOTE] Tags are only collected during cacheable HTTP methods (GET, HEAD). POST/PUT/DELETE requests never contribute to the response tag set.
[!IMPORTANT] By default (
scope: controller) collection starts atkernel.controller: elements loaded before rendering (e.g. inkernel.requestlisteners) are not tagged. Setscope: requestto collect from the very beginning of the request.
2. Fallback document tagging
Every route other than a document route (e.g. static route, symfony route) has no document of its own. For these, OpenDXP still resolves a "fallback" document, the nearest document found by path.
By default, this fallback document is also tagged and invalidated like any other content document.
If your route's response does not actually depend on that fallback document,
set tag_fallback_document: false to stop tagging/invalidating it for routes that only resolved a fallback.
3. Tags written to response header
FOSHttpCacheBundle's TagListener writes all collected tags to the response header configured for the active proxy client. For example, with Varnish ban mode:
X-Cache-Tags: document_42 data_object_17 data_object_class_employee asset_22
The reverse proxy stores this header alongside the cached response and strips it before delivering to the browser.
4. Invalidation on content change
When a Document, DataObject, Asset, Translation or WebsiteSetting is saved or deleted, ElementChangeListener calls HttpCache::invalidate() directly. FOSHttpCacheBundle's InvalidationListener flushes all queued invalidation requests to the proxy after the response is sent (kernel.terminate).
Save Document 42
→ ElementChangeListener::onDocumentChange()
→ HttpCache::invalidate($document)
→ OpenDxpElementCacheStrategy::getTags() → ['document_42', 'document_list']
→ CacheManager::invalidateTags(['document_42', 'document_list'])
→ kernel.terminate → FOSHttpCacheBundle flushes to proxy
→ Varnish/Fastly/etc. invalidates all responses tagged document_42 or document_list
Setup
1. Install and register FOSHttpCacheBundle
composer require friendsofsymfony/http-cache-bundle
// config/bundles.php
FOS\HttpCacheBundle\FOSHttpCacheBundle::class => ['all' => true],
If opendxp.http_cache.enabled: true but the bundle is missing or not registered, OpenDXP throws a LogicException at container compile time with install instructions.
2. Enable in OpenDXP
# config/packages/opendxp.yaml
opendxp:
http_cache:
enabled: true
3. Configure FOSHttpCacheBundle
OpenDXP integrates with FOSHttpCacheBundle's proxy client infrastructure. Configure the proxy client for your environment:
# config/packages/fos_http_cache.yaml
fos_http_cache:
proxy_client:
varnish:
tags_header: My-Cache-Tags
tag_mode: ban
header_length: 1234
http:
servers:
- 123.123.123.1:6060
- 123.123.123.2
base_url: yourwebsite.com
tags:
enabled: true
Supported proxy clients: Varnish, Fastly, nginx, Cloudflare, Symfony HttpCache. See FOSHttpCacheBundle proxy client docs.
4. Configure Cache-Control headers
OpenDXP does not set Cache-Control headers automatically.
Use FOSHttpCacheBundle's cache_control rules to define which responses should be cached and for how long.
The following example globally enables caching for all non-admin GET/HEAD requests.
This could be be a good starting point for most projects:
# config/packages/fos_http_cache.yaml
fos_http_cache:
cache_control:
defaults:
overwrite: false
rules:
-
match:
path: ^(?!/(admin))
headers:
cache_control:
public: true
max_age: 15
s_maxage: 30
etag: true
See FOSHttpCacheBundle caching headers docs.
Configuration reference
opendxp:
http_cache:
enabled: true
scope: controller # "controller" (default) or "request": when collection starts
tag_fallback_document: true # tag/invalidate the fallback document on routes without their own (default: true)
elements:
documents:
enabled: true # tag/invalidate documents (default: true)
tag_list: true # also tag/invalidate "document_list" (default: true)
data_objects:
enabled: true # tag/invalidate data objects (default: true)
tag_list: true # also tag/invalidate "data_object_class_{className}" (default: true)
assets:
enabled: true # tag/invalidate assets (default: true)
tag_list: true # also tag/invalidate "asset_list" (default: true)
translations:
enabled: true # invalidate on translation changes (default: true)
website_settings:
enabled: true # tag/invalidate website settings (default: true)
All elements options default to true. The full configuration above is equivalent to just setting enabled: true.
Disabling an element type affects both tag collection (response header) and invalidation (purge on save).
Example (disable asset tracking entirely):
opendxp:
http_cache:
enabled: true
elements:
assets:
enabled: false
Project-specific tags
1. Via FOSHttpCacheBundle (controller / route / Twig)
Tag a controller action:
use FOS\HttpCacheBundle\Configuration\Tag;
#[Tag('news')]
#[Tag(expression: '"news-" ~ id')]
public function articleAction(int $id): Response { ... }
Tag from a Twig template:
{{ fos_httpcache_tag('news') }}
{{ fos_httpcache_tag(['news', 'news-' ~ article.id]) }}
Via YAML rules (no code changes needed):
fos_http_cache:
tags:
rules:
- { match: { path: ^/news }, tags: [news] }
See FOSHttpCacheBundle tagging docs for the full reference.
2. Via Doctrine entity strategy
For Doctrine entities that need a single {prefix}_{id} tag, use the DoctrineGeneralEntityCacheStrategy service.
Collection (on postLoad) and invalidation (on postPersist/postUpdate/postRemove) are handled automatically,
which only fires for the registered entity classes.
# config/services/http_cache.yaml
app.http_cache.my_entity:
class: OpenDxp\HttpCache\DoctrineGeneralEntityCacheStrategy
tags:
- name: opendxp.http_cache.doctrine_entity
entity_class: App\Entity\MyEntity
tag_prefix: app_my_entity
# identifier_expression: 'object.getId()' # default / optional
This produces tags like app_my_entity_42.
[!NOTE]
identifier_expressionis a Symfony expression evaluated withobjectas the entity instance.
3. Via custom strategy
Use this if you need more control about tagging or custom (special) entities.
Implement HttpCacheTagStrategyInterface directly and register a HttpCacheTagStrategyInterface service
to cover both collection and invalidation for a custom element type with a single class:
use OpenDxp\HttpCache\HttpCacheTagStrategyInterface;
use OpenDxp\HttpCache\Tag\CacheTag;
class BlogPostCacheStrategy implements HttpCacheTagStrategyInterface
{
public function supports(object $element): bool
{
return $element instanceof BlogPost;
}
public function getTags(object $element): array
{
return [
new CacheTag(BlogTagType::Post, $element->getId()),
new CacheTag(BlogTagType::PostList),
];
}
}
Define your tag types with CacheTagType:
enum BlogTagType: string implements CacheTagType
{
case Post = 'blog';
case PostList = 'blog_list';
public function prefix(): string { return $this->value; }
}
Register the strategy with the DI tag:
# config/services.yaml
App\Cache\BlogPostCacheStrategy:
tags:
- { name: opendxp.http_cache.strategy }
Collection: call when a BlogPost is loaded:
// In a postLoad listener, or repository, or controller:
$this->invalidator->collectTagsFor($blogPost);
Invalidation: call when a BlogPost changes:
// In a postUpdate/postDelete listener or wherever mutations happen:
$this->invalidator->invalidate($blogPost);
Inject HttpCache from the container:
use OpenDxp\HttpCache\HttpCache;
class BlogPostRepository
{
public function __construct(private readonly HttpCache $httpCache) {}
}
Skipping invalidation
To skip cache invalidation for a specific save operation, pass HttpCacheArguments::SKIP_INVALIDATION:
use OpenDxp\HttpCache\HttpCacheArguments;
// Publish silently without purging the cache
$document->save([HttpCacheArguments::SKIP_INVALIDATION => true]);
saveVersionOnly and autoSave operations are automatically excluded from invalidation.
Filtering tag collection
Subscribe to HttpCacheEvents::TAG_GUARD to conditionally prevent tags from being tracked for specific elements.
This event fires before tags enter the response collector. Cancelling it prevents the tags from ever being written to the header.
use OpenDxp\Event\HttpCache\HttpCacheTagGuardEvent;
use OpenDxp\Event\HttpCacheEvents;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
#[AsEventListener(event: HttpCacheEvents::TAG_GUARD)]
class InternalDocumentTagGuard
{
public function __invoke(HttpCacheTagGuardEvent $event): void
{
if ($event->element instanceof Document && $event->element->getProperty('internal')) {
$event->cancel(); // tags are never added to the collector
}
}
}
To add or modify tags, register a HttpCacheTagStrategyInterface strategy instead: the guard is a filter only.
Disabling tag collection
Entire request
Inject HttpCacheScope and call disable() to prevent tag collection for the entire request.
use OpenDxp\HttpCache\HttpCacheScope;
public function __construct(private readonly HttpCacheScope $httpCacheScope) {}
public function accountAction(): Response
{
$this->httpCacheScope->disable(); // no tags collected for this request
// ...
}
Specific code block (e.g. inside an areabrick)
Use suspended() to temporarily suppress tag collection for a specific block of code.
Collection resumes automatically after the callable returns.
// Elements loaded inside the closure are not tagged.
// Other elements loaded outside it are tagged normally.
$this->httpCacheScope->suspended(function () {
$this->relatedItems->load();
});
Force collection
By default, collection only starts at kernel.controller (scope: controller).
Two options for collecting tags earlier:
Option 1: scope: request: Set in the config to enable collection for the entire request globally.
Option 2: collecting(callable): Force-enable collection for a specific block only, without changing the global scope.
It force-enables collection for the duration of the callable, regardless of the current scope state, and restores the previous state afterwards.
use OpenDxp\HttpCache\HttpCacheScope;
use OpenDxp\HttpCache\HttpCache;
public function onKernelRequest(RequestEvent $event): void
{
$site = $this->siteResolver->resolve($event->getRequest());
if ($site !== null) {
$this->httpCacheScope->collecting(function () use ($site) {
$this->httpCache->collectTagsFor($site);
});
}
}
ESI fragments
For pages mixing public and user-specific content, use Symfony's ESI support. Each ESI fragment is a separate HTTP request, it gets its own independent cache tags header and can be cached and invalidated separately.
{# Main page is publicly cached. Cart badge is fetched per-request. #}
{{ render_esi(controller('App\\Controller\\Cart::badgeAction')) }}
#[Cache(smaxage: 0)]
public function badgeAction(): Response
{
$response = $this->render('cart/badge.html.twig');
$response->setPrivate();
return $response;
}
Symfony Web Profiler
When the Symfony profiler is activ, an HTTP Cache Tags panel is available in the toolbar. It shows all tags collected for the current request, grouped by type.
Proxy configuration
OpenDXP delegates all proxy communication to FOSHttpCacheBundle. Supported backends: Varnish (BAN and xkey/purgekeys), Fastly, nginx, Cloudflare, Symfony HttpCache.
Symfony HttpCache Example
Symfony ships with a built-in reverse proxy (HttpCache) that runs in-process.
This an example of how to use it with OpenDXP.
1. Install the tag-aware store
The default Symfony store does not support tag-based invalidation. Install the PSR-6 store from toflar:
composer require toflar/psr6-symfony-http-cache-store
2. Create src/AppCache.php
namespace App;
use FOS\HttpCache\SymfonyCache\CacheInvalidation;
use FOS\HttpCache\SymfonyCache\CleanupCacheTagsListener;
use FOS\HttpCache\SymfonyCache\EventDispatchingHttpCache;
use FOS\HttpCache\SymfonyCache\PurgeListener;
use FOS\HttpCache\SymfonyCache\PurgeTagsListener;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\HttpCache\HttpCache;
use Symfony\Component\HttpKernel\HttpCache\SurrogateInterface;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Toflar\Psr6HttpCacheStore\Psr6Store;
class AppCache extends HttpCache implements CacheInvalidation
{
use EventDispatchingHttpCache;
public function __construct(
HttpKernelInterface $kernel,
?SurrogateInterface $surrogate = null,
array $options = [],
) {
$store = new Psr6Store([
'cache_directory' => $kernel->getCacheDir() . '/http_cache',
'cache_tags_header' => PurgeTagsListener::DEFAULT_TAGS_HEADER,
]);
parent::__construct($kernel, $store, $surrogate, array_merge(['debug' => true], $options));
$this->addSubscriber(new PurgeListener());
$this->addSubscriber(new PurgeTagsListener());
$this->addSubscriber(new CleanupCacheTagsListener());
}
public function fetch(Request $request, bool $catch = false): Response
{
return parent::fetch($request, $catch);
}
}
3. Update src/Kernel.php
The kernel must implement HttpCacheProvider so FOSHttpCacheBundle can dispatch tag invalidation requests directly to AppCache without HTTP.
namespace App;
use FOS\HttpCache\SymfonyCache\HttpCacheAware;
use FOS\HttpCache\SymfonyCache\HttpCacheProvider;
use OpenDxp\Kernel as OpenDxpKernel;
class Kernel extends OpenDxpKernel implements HttpCacheProvider
{
use HttpCacheAware;
public function __construct(string $environment, bool $debug)
{
parent::__construct($environment, $debug);
$this->setHttpCache(new AppCache($this));
}
}
4. Update public/index.php
Return the AppCache kernel instead of the inner kernel when it is available:
return static function () {
Bootstrap::bootstrap();
$kernel = Bootstrap::kernel();
if ($kernel instanceof \FOS\HttpCache\SymfonyCache\HttpCacheProvider) {
return $kernel->getHttpCache();
}
return $kernel;
};
5. Configure FOSHttpCacheBundle
# config/packages/fos_http_cache.yaml
fos_http_cache:
proxy_client:
symfony:
use_kernel_dispatcher: true
tags:
enabled: true
use_kernel_dispatcher: true routes invalidation calls directly to AppCache in-process.
No HTTP server setup is needed.