zenstruck/cache-bundle
Zenstruck CacheBundle adds a convenient cache service to Symfony, wrapping PSR-6 cache pools with a simple API and commands for clearing cached data. Configure pools, store values, and invalidate them easily without boilerplate—ideal for application-level caching.
This bundle adds a console command that primes an HTTP cache (Varnish, a CDN, or Symfony's AppCache) by issuing real HTTP requests to a list of URLs — either configured explicitly or discovered from a sitemap. It is archived and targets Symfony2, so the practical first step is confirming it will even install before spending time on it.
1. Check compatibility first — before anything else.
composer show zenstruck/cache-bundle --all
The package was last released in Feb 2016 and declares Symfony 2.x constraints. If your app is on Symfony 4+ (or PHP 8), the bundle's use statements, service definitions, and AppKernel registration model will not work without patching. In that case, stop — see "Gotchas" for the two-line replacement.
2. Install (Symfony 2/3 only), dev-only.
composer require --dev "zenstruck/cache-bundle:~1.0"
It is a deploy/build-time tool, not a runtime dependency. Register it in AppKernel.php under registerDevBundles() or registerTestBundles() only:
if (in_array($this->getEnvironment(), array('dev', 'test'), true)) {
$bundles[] = new Zenstruck\Bundle\CacheBundle\ZenstruckCacheBundle();
}
3. Look here first. Once installed, the files worth reading in vendor/zenstruck/cache-bundle/ are, in order:
Command/ — the warmup command itself; this defines the actual behavior, options, and output.DependencyInjection/Configuration.php — the exact config keys. Do not guess the zenstruck_cache: schema from blog posts; the archived README may lag the code.Resources/config/ — the URL provider / HTTP client wiring, so you know whether it uses Guzzle, file_get_contents, or a crawler service you can swap.4. First use case: post-deploy warmup. Point it at your public hostname and run it right after cache:clear:
app/console zenstruck:cache:warmup --env=prod
The single most important thing to verify on the first run: the command is hitting the front proxy / web server (the layer that owns the cache), not 127.0.0.1:app-port. Warming the backend process warms nothing.
Sitemap-driven warmup (the canonical pattern). Configure the sitemap as the source of truth so the command stays in sync with your content automatically, rather than maintaining a duplicate URL list:
# app/config/config.yml
zenstruck_cache:
warmup:
sitemap: "http://www.example.com/sitemap.xml"
This is the highest-leverage configuration: the sitemap already enumerates every canonical public URL, so there is no second list to drift.
Explicit URL lists for non-crawlable surfaces. For landing pages, category pages, or high-traffic routes that aren't in the sitemap, keep a hand-maintained list in config:
zenstruck_cache:
warmup:
urls:
- "http://www.example.com/"
- "http://www.example.com/pricing"
Combine sitemap and urls if the bundle supports both concurrently; if not, put the extra URLs in a separate sitemap and reference that.
Wire it into the deploy, not into a developer's memory. The pattern that actually works is a post-deploy step that runs only after the new code is live on every node:
# deploy script, after symlink switch and cache:clear
app/console cache:clear --env=prod --no-debug
app/console zenstruck:cache:warmup --env=prod --no-debug || true
Guard with || true initially: a warmup failure should not roll back a deploy that is otherwise healthy. Promote it to a hard failure only once you trust it.
Warm the cache through the proxy, from inside the network. Set the target host to the public hostname (or an internal VIP that resolves to the load balancer) so requests traverse Varnish/CDN and populate the real cache. If your environment requires a Host header override, prefer configuring the hostname in the bundle config over editing DNS.
Run it as a one-off task, not on every request or on a cron for its own sake. The natural trigger is deploy, plus optionally after a bulk content import. A nightly cron is legitimate only if your cache TTLs are short enough that entries routinely expire before the next deploy.
Pair it with AppCache correctly. Symfony2's AppCache is a per-machine, on-disk reverse proxy. If you run multiple app servers, warmup executed on one box populates only that box — you must run the command on each node, or warm through a shared Varnish instead. This is a design consequence, not a bug, and it's the most common reason people conclude "warmup doesn't work."
Isolate the HTTP client as an extension point. The command's value is its URL enumeration, not its HTTP calls. If the bundled client proves too slow or can't set the headers you need, replace the client service in your app's service config with a Guzzle client configured for concurrency, keeping the command and its URL providers intact.
It is archived — treat it as vendored source, not a dependency. There will be no fixes for PHP 8 deprecations, Symfony 4+ autowiring, or security issues in the HTTP client. Fork it into src/ or copy the ~one command you need. For most teams the entire feature is replaceable by a few lines:
# equivalent, no dependency, works on any Symfony version
while read -r url; do curl -sf -o /dev/null "$url"; done < urls.txt
# or, with sitemap extraction:
curl -s https://example.com/sitemap.xml \
| grep -o '<loc>[^<]*' | sed 's/<loc>//' \
| xargs -P8 -I{} curl -sf -o /dev/null {}
Warming the wrong layer is the #1 failure. Symptoms: the command reports success, all URLs return 200, yet hit rate doesn't move. Cause: requests went to the PHP backend directly (or bypassed the proxy due to a hosts entry, no_proxy, or a Docker network alias). Verify with curl -I against the same URL the command uses and check for X-Varnish, Age, X-Cache, or CF-Cache-Status headers.
Auth-protected and error URLs get cached. If a crawled URL 302s to /login, the warmup stores a redirect under the public URL — worse than a cold cache. Audit the sitemap and URL list for anything behind auth, and confirm the command's response-code handling by reading the command source rather than assuming it skips non-200s.
Sitemap crawling can explode. A large sitemap means hundreds or thousands of sequential requests, each of which may itself trigger expensive cache-miss renders. Watch for PHP max_execution_time and memory limits when run via app/console (CLI limits differ from FPM). If the command is sequential, run it with nohup/& in deploy scripts or bound it to a subset of URLs, and be aware you're generating a burst of load on a freshly deployed, possibly cold, app.
Old-syntax config bites. Symfony2-era bundles often use %parameter% substitution, services.yml with arguments: [@service], and ::class-free PHP. If you copy config from the README into a modern app, the indentation, key names, and service references will silently no-op. Validate with app/console config:dump-reference zenstruck_cache — if that command errors, the bundle didn't register and nothing else you configured matters.
Command registration is env-dependent. Because you registered the bundle in registerDevBundles(), the command is invisible in prod — which is exactly where you need it for deploys. Register it in all environments if you rely on it in deploy scripts, and accept the small surface area increase, or invoke it as a standalone script.
Extension points worth knowing. The bundle's real seams are the URL provider (sitemap vs. list) and the HTTP client. If you keep the package, inject your own implementation of either rather than editing vendor code — that keeps the migration path open when you eventually replace it. If you're already forking, delete the bundle layer entirely and keep the command as an application-level src/Command/ class; you lose nothing and shed the Symfony2 bundle indirection that will break on the next major upgrade.
How can I help you explore Laravel packages today?