asm89/twig-cache-extension
Adds cache tags and helpers to Twig so you can cache expensive template fragments and partials. Supports cache keys, lifetimes/expiration, and PSR-6 cache pools for faster rendering in Symfony or standalone Twig projects.
asm89/twig-cache-extension adds a {% cache %} tag to Twig, so you can wrap an expensive-to-render fragment (a menu, a product card grid, a "related posts" block) and skip re-rendering it on subsequent requests. The repository is archived, so treat it as a stable-but-frozen dependency: pin the version, check its Twig constraint against your stack, and plan a migration path (see Gotchas).
Install and wire it up:
composer require asm89/twig-cache-extension
use Asm89\Twig\CacheExtension\CacheProvider\PsrCacheAdapter;
use Asm89\Twig\CacheExtension\CacheStrategy\KeyGenerator\DefaultKeyGenerator;
use Asm89\Twig\CacheExtension\CacheStrategy\LifetimeCache;
use Asm89\Twig\CacheExtension\Extension;
$pool = new \Symfony\Component\Cache\Adapter\FilesystemAdapter();
$keyGenerator = new DefaultKeyGenerator();
// provider + key generator + default lifetime (seconds)
$strategy = new LifetimeCache(new PsrCacheAdapter($pool), $keyGenerator, 3600);
$twig->addExtension(new Extension($strategy));
Note the shape: the Extension takes a strategy, and the strategy takes the cache provider + key generator. That indirection is the thing people get wrong first.
In a Symfony app (autowiring off, explicit ids):
services:
Asm89\Twig\CacheExtension\CacheProvider\PsrCacheAdapter:
arguments: ['@cache.app']
Asm89\Twig\CacheExtension\CacheStrategy\KeyGenerator\DefaultKeyGenerator: ~
Asm89\Twig\CacheExtension\CacheStrategy\LifetimeCache:
arguments: ['@Asm89\Twig\CacheExtension\CacheProvider\PsrCacheAdapter', '@Asm89\Twig\CacheExtension\CacheStrategy\KeyGenerator\DefaultKeyGenerator', 3600]
Asm89\Twig\CacheExtension\Extension:
arguments: ['@Asm89\Twig\CacheExtension\CacheStrategy\LifetimeCache']
tags: ['twig.extension']
First use case: pick one partial that is pure output (no {% set %} side effects), wrap it, and verify a hit by changing the underlying data and confirming the page doesn't change until TTL expiry.
{# templates/home/index.html.twig #}
{% cache "home:promo:" ~ promo.id ttl(300) %}
{{ include('home/_promo.html.twig', { promo: promo }) }}
{% endcache %}
Pick the strategy that matches who owns population.
ReadWriteCache — classic cache-aside: miss → render → save() with the lifetime fixed at construction. Use for "render it, cache it, move on".LifetimeCache — same, but each tag can override the TTL with the ttl(...) modifier. Use when fragments have wildly different freshness needs (nav: 1h, stock badge: 30s).ReadOnlyCache — never writes. Use with a warmer (console command, kernel.terminate listener, cron) that renders fragments and saves them, so web requests never pay the render cost. Its save() is a no-op, so a miss just renders uncached — degrade gracefully, don't throw.Design keys as namespaced strings, always. The tag's argument is passed to the key generator verbatim; nothing namespaces it by template. Adopt a convention like template:purpose:identifier:dimensions:
{% cache "product:card:" ~ product.id ~ ":locale:" ~ app.request.locale ~ ":cur:" ~ currency %}
Implement KeyGeneratorInterface when keys get long or structured. PSR-6 forbids { } ( ) / \ @ : in keys and many adapters cap key length, so hash:
final class NamespacedKeyGenerator implements KeyGeneratorInterface
{
public function __construct(private string $namespace) {}
public function generateKey($value): string
{
return $this->namespace . ':' . sha1((string) $value);
}
}
Invalidate by versioning the namespace, not by deleting keys. Since there's no tag-invalidation API, keep a version counter in config/DB and include it in the key; bumping it orphans old entries to expire naturally.
$keyGenerator = new NamespacedKeyGenerator('v' . $this->config->get('fragment_cache_version'));
Use it per item inside loops. The key expression is evaluated per iteration, so one tag around the loop caches one blob; one tag inside the loop caches N fragments you can invalidate independently:
{% for product in products %}
{% cache "product:row:" ~ product.id ~ ":" ~ product.updatedAt|date('U') %}{# updatedAt as a cheap implicit invalidator #}
{{ include('product/_row.html.twig') }}
{% endcache %}
{% endfor %}
Decorate the strategy for observability and stale-while-revalidate. CacheStrategyInterface (generateKey, fetch, save) is the intended extension point — wrap it to log hit/miss ratios, emit metrics, or serve stale content and refresh out-of-band:
final class InstrumentedStrategy implements CacheStrategyInterface
{
public function __construct(
private CacheStrategyInterface $inner,
private LoggerInterface $logger,
) {}
public function generateKey($value) { return $this->inner->generateKey($value); }
public function fetch($key)
{
$hit = $this->inner->fetch($key);
$this->logger->debug($hit === false ? 'fragment miss' : 'fragment hit', ['key' => $key]);
return $hit;
}
public function save($key, $value) { $this->inner->save($key, $value); }
}
Test with an in-memory pool and assert both the first and second render:
$pool = new \Symfony\Component\Cache\Adapter\ArrayAdapter();
$strategy = new ReadWriteCache(new PsrCacheAdapter($pool), new DefaultKeyGenerator(), 60);
$twig->addExtension(new Extension($strategy));
$first = $twig->render('product/_card.html.twig', ['product' => $p]);
$second = $twig->render('product/_card.html.twig', ['product' => $p]);
self::assertSame($first, $second);
self::assertCount(1, $pool->getValues()); // proves the write happened
Use a distinct pool per environment. Sharing cache.app between dev and prod (or between two apps on one Redis) means stale or colliding fragments. Prefix the pool namespace (FilesystemAdapter($namespace = 'app_fragments', ...)) and keep the TTLs short for anything that smells dynamic.
twig/cache-extra ships an official {% cache "key" ttl(300) %} tag with nearly identical syntax — migration is mostly deleting the strategy wiring and passing a PSR-16 cache instead.fetch() returns false, not null, on a miss. Any custom strategy must respect that sentinel, and anything you cache must never legitimately be false.DefaultKeyGenerator uses spl_object_hash() for objects, which is per-process and per-request — passing a Doctrine entity or a DateTime gives you a key that never hits across requests. Always pass a string (id, slug, updatedAt timestamp), or supply your own generator.{% cache "sidebar" %} share an entry and will serve each other's HTML. Prefix everything.ttl() only means something on LifetimeCache. With ReadWriteCache the lifetime is the constructor argument and the modifier is ignored — a silent source of "why is this stale for an hour".{% set %}, {% do %}, counters, and anything else inside the tag are skipped, which breaks layouts that depend on variables assigned there. Keep the tag around output only.0 as a lifetime. Depending on the provider it means "forever" — fine for a truly static nav, a footgun for user data.Vary header equivalent here; the key is the only dimension.twig/cache-extra — both claim the cache tag, and which one wins is confusing at best.bin/console cache:clear won't clear your fragment pool. Add an explicit purge (or bump the key namespace version) to your deploy script so fragments referencing renamed templates/fields don't linger.{%- cache -%} and friends trim the rendered fragment too; diff the raw HTML before/after wrapping if spacing shifts.How can I help you explore Laravel packages today?