Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Twig Cache Extension Laravel Package

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.

Deep Wiki
Context7

Getting Started

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 %}

Implementation Patterns

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.

Gotchas and Tips

  • Archived + Twig 3 friction. The 1.x line targets Twig 1/2; on modern Twig check the constraint before assuming it loads, and expect deprecation noise from the extension class hierarchy. If you're on Twig ≥ 3.11, 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.
  • No key namespacing, no collision detection. Two templates using {% 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".
  • Side effects inside the block don't run on a hit. {% 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.
  • No stampede protection. On a cold key, every concurrent request renders the fragment. For hot fragments, warm ahead of traffic or add a lock inside a custom strategy.
  • Output buffering and exceptions. The compiled tag buffers output; a throwing include can leave the buffer dirty. Keep fragments small and let your error handler deal with it, or wrap risky includes outside the tag.
  • PSR-6 key restrictions and length limits. Colons, slashes, braces and long keys blow up on some adapters (and some throw instead of returning a miss). Hash anything derived from a URL or a serialized object.
  • Beware 0 as a lifetime. Depending on the provider it means "forever" — fine for a truly static nav, a footgun for user data.
  • User-specific content must be in the key: user id, role, locale, currency, A/B bucket. There's no Vary header equivalent here; the key is the only dimension.
  • Don't register both this extension and 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.
  • Whitespace control inside the tag is captured. {%- cache -%} and friends trim the rendered fragment too; diff the raw HTML before/after wrapping if spacing shifts.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings