knplabs/knp-zend-cache-bundle
A Symfony bundle that integrates Zend Cache, letting you configure and expose cache storage adapters as services via the cache manager and inspect cache activity with a web profiler panel.
First, confirm this is the right context at all. This bundle is archived, explicitly unmaintained, and depends on the EOL zendframework/zend-cache (later laminas/laminas-cache, which reshaped the API). It will only install and boot inside a legacy Symfony 2.8/3.x app on PHP 5.6–7.x that already has compatible constraints. If composer require fails on platform requirements, stop — that is the intended answer, and the correct move is symfony/cache pools, not a workaround.
Install and register (legacy app only):
composer require knplabs/knp-zend-cache-bundle
// app/AppKernel.php
$bundles = [
// ...
new Knp\Bundle\ZendCacheBundle\KnpZendCacheBundle(),
];
Where to look first. Documentation is thin, so read the source in this order — it is the only reliable reference:
DependencyInjection/Configuration.php — the authoritative config tree. Adapter names and their options live here.Resources/config/*.xml (or *.yml) — the service IDs you will inject.Command/ — the clear/clean console commands and their exact names.DataCollector/ + Resources/views/ — what the web profiler panel actually records.You can dump the config tree rather than guess:
bin/console config:dump-reference knp_zend_cache
Minimal working configuration — one filesystem storage, injected by service ID:
# app/config/config.yml
knp_zend_cache:
storage:
default:
adapter:
name: filesystem
options:
cache_dir: "%kernel.cache_dir%/zend_cache"
sessions:
adapter:
name: memcached
options:
servers:
- { host: 127.0.0.1, port: 11211 }
First use case — inject the configured storage into a service and use the underlying Zend Cache StorageInterface API directly:
use Zend\Cache\Storage\StorageInterface;
class PriceListRepository
{
private $cache;
public function __construct(StorageInterface $cache)
{
$this->cache = $cache;
}
public function findAll()
{
$key = 'price_list';
if (null === $data = $this->cache->getItem($key)) {
$data = $this->loadFromDatabase();
$this->cache->setItem($key, $data);
}
return $data;
}
}
# wiring
services:
app.price_list_repository:
class: PriceListRepository
arguments: ["@knp_zend_cache.storage.default"] # confirm the exact ID from the bundle's config files
First verification loop: open the profiler in dev, hit the page twice, and confirm the panel shows one miss then one hit. Then run the bundle's clear command and confirm the panel flips back to a miss. If that loop works, the wiring is correct.
Storage per concern, not one global cache. The bundle's main structural value is letting you declare several named storages (default, sessions, staging, thumbs) with different adapters and options, each becoming its own injectable service. Keep the name tied to the data domain, not the adapter — thumbnails should not have to be renamed when you move it from filesystem to Redis.
knp_zend_cache:
storage:
long_lived: { adapter: { name: filesystem, options: { cache_dir: "%kernel.cache_dir%/long" } } }
volatile: { adapter: { name: apcu } }
Inject the storage service, never the container. The bundle's whole contract is "configure in YAML, resolve via DI." If code reaches into $container->get('knp_zend_cache.storage.default') you lose the only benefit the package offers and make the eventual migration harder.
Wrap it in one thin application-level cache service. Even inside the legacy app, put a App\Cache\CacheInterface in front of the Zend storage. Then migrating to symfony/cache is a change of one adapter class rather than a sweep of every call site. This is the single highest-leverage pattern when you inherit this bundle — you are effectively pre-paying the exit.
final class ZendCacheAdapter implements CacheInterface
{
public function __construct(private StorageInterface $storage) {}
public function remember(string $key, int $ttl, callable $producer)
{
if (null !== $hit = $this->storage->getItem($key)) {
return $hit;
}
$value = $producer();
$this->storage->setItem($key, $value); // plus explicit TTL handling, see Gotchas
return $value;
}
}
Batch reads with getItems(), not loops of getItem(). The Zend adapters round-trip more efficiently in bulk, and this is the pattern that carries over cleanly to PSR-6's getItems(). Mind the miss semantics — see Gotchas.
$keys = array_map(fn($id) => "product_{$id}", $ids);
$found = $this->storage->getItems($keys); // returns only the HIT keys
$missed = array_diff_key(array_flip($keys), $found);
Namespace via adapter options, not key prefixes in code. The Zend adapters accept a namespace option that is applied by the adapter. Doing it by hand in every call site means two places to change later and one place to forget:
storage:
products:
adapter:
name: filesystem
options:
cache_dir: "%kernel.cache_dir%/products"
namespace: "products_v3"
Bump the namespace suffix as a cheap versioning / mass-invalidation strategy — it costs nothing and is the closest thing this stack has to tag-based invalidation.
Use the profiler as your primary debugging tool. The bundle's data collector logs cache calls with hits and misses. In practice: keep the Symfony toolbar open while exercising a feature, and confirm the hit/miss pattern is what you designed — the most common bug in legacy cache code is a cache that is technically working but missing 100% of the time because the key varies (product_1 vs products.1).
Wire the console commands into deploy scripts, not into developer memory. Typical use:
bin/console cache:clear # Symfony's own cache — does NOT touch Zend storage
bin/console knp:zend-cache:clear ... # bundle's command — verify name via `bin/console list`
Note the two-tier distinction the bundle exposes: clear (remove entries) versus clean (expire them / sweep by pattern). Check the command help before putting either in CI — misusing clean in a deploy is silent and non-destructive-looking, which makes it worse than a failure.
Test against the array/in-memory adapter. Zend Cache ships an in-memory adapter; point the test environment's storage config at it so unit tests never touch the filesystem or a real Memcached node. This also decouples tests from the legacy backend, which helps at migration time.
Reuse Zend Cache patterns only deliberately. The bundle wires storage adapters; the Zend Cache Pattern layer (callback, capture, output caching) sits above it and has no drop-in Symfony equivalent. If existing code reaches for Zend\Cache\Pattern, isolate it behind your own interface early — those call sites are the long pole in any replacement effort and cannot be mechanically swapped.
The archive banner is the headline gotcha. zendframework/zend-cache is abandoned with no security-response path, and the bundle has zero Packagist dependents — meaning no community patch pressure and no ecosystem signal. Any composer update is an unguarded failure with no upstream fix. Practical mitigation while you stay on it: pin via composer.lock, add the package to an explicit allowlist so composer audit findings are acknowledged rather than permanently ignored, and name an internal owner. Do not fork it — a caching abstraction is a commodity, and an internal fork of a 38-star archived bundle reliably becomes an orphaned product.
getItem() returns null on miss — which is ambiguous. If you legitimately cache null, a miss and a hit are indistinguishable and you will silently re-fetch forever. Cache a sentinel wrapper (e.g. ['v' => $value]) if null is a legal cached value.
getItems() omits misses rather than returning null placeholders. Code that does $found['key'] after a batch read throws a notice/undefined-index instead of cleanly missing. Always array_diff_key against the requested key list.
TTL is not where you expect it. Zend Cache's setItem() signature takes options, and adapter-specific TTL rounding/swallowing differs. Do not assume setItem($key, $value, $ttl) — check the adapter's actual option handling, and prefer setting TTL through a wrapper so every call site behaves identically. This is exactly the class of semantic drift that produces wrong-but-working caches after migration.
cache:clear will not purge your Zend storages. Symfony's filesystem purge and the bundle's storages are unrelated. A deploy that runs only cache:clear leaves stale entries keyed to old code. If your cache_dir lives under %kernel.cache_dir%, be aware that cache:clear may delete the directory while the adapter still holds stale in-process state — order your deploy steps explicitly.
Filesystem adapter: permissions and path handling. The adapter creates hashed subdirectories; a web user and a CLI user with different umasks silently produce two disjoint caches, so "it works in dev, misses in prod" is a classic. Also treat cache_dir traversal and key derivation as security-relevant: if keys are built from user or tenant input, key injection becomes a filesystem concern rather than a cache concern.
Keys must fit every backend's limits. Memcached caps key length (and disallows whitespace/control characters); the filesystem adapter hashes. A key scheme that works locally on filesystem can fail or truncate on Memcached — meaning collisions. Keep keys short, deterministic, and hash long inputs yourself.
YAML can't express every adapter option. The config tree flattens adapter options into arrays; object-valued options (custom serializers, hashers, Memcached client instances) generally cannot be declared there. The escape hatch is to define the Zend adapter as your own service and alias the bundle's storage service ID to it — that keeps injection points stable while letting you construct the adapter in PHP.
Don't run the profiler in prod. The data collector wraps cache calls; it is a dev/debug tool and belongs behind framework.profiler in the dev/test config only.
Extension points that actually help: alias or decorate the bundle's storage service IDs rather than editing vendor code, and keep your App\Cache\CacheInterface wrapper as the only thing application code depends on. That combination means the eventual symfony/cache migration is a service-definition change plus one adapter class — days, not weeks. Expect a cold cache on cutover: key hashing, namespaces, and serialization formats differ across the boundary, so plan a warmup or backfill rather than an in-place format migration.
How can I help you explore Laravel packages today?