twig/cache-extra
Twig Cache Extension integrates the Symfony Cache component into Twig, adding a single cache tag that lets you cache template fragments directly in your templates for faster rendering.
Laravel ships Blade. twig/cache-extra is only worth anything in a Laravel app that already renders Twig — a legacy Symfony/Twig module, a microservice template layer, or an explicit twig/twig integration you maintain yourself. If you are 95% Blade, stop here and use Laravel's native @cache/@endcache directive instead; it does the same job without a second cache stack.
Assuming Twig is already in play, look at three things before writing any template code:
composer.json — is twig/twig pinned to 3.x? cache-extra v3 requires Twig 3.AppServiceProvider binding or a small package. This is where the extension gets registered.config/cache.php — decide now which store backs fragment caching. Use a dedicated store (twig_fragments) so you can flush template fragments without nuking sessions, queues-side caches, or API caches.composer require twig/twig twig/cache-extra symfony/cache
// app/Providers/TwigServiceProvider.php
use Symfony\Component\Cache\Adapter\Psr16Adapter;
use Twig\Environment;
use Twig\Extra\Cache\CacheExtension;
use Twig\Loader\FilesystemLoader;
$this->app->singleton(Environment::class, function ($app) {
$twig = new Environment(
new FilesystemLoader(resource_path('twig')),
['cache' => storage_path('framework/views/twig'), 'debug' => $app->isLocal()]
);
// Laravel's Repository is PSR-16; Psr16Adapter turns it into the PSR-6
// Symfony\Contracts\Cache\CacheInterface the extension demands.
$twig->addExtension(new CacheExtension(
new Psr16Adapter($app['cache']->store('twig_fragments'))
));
return $twig;
});
Add the store in config/cache.php ('twig_fragments' => ['driver' => 'redis', 'connection' => 'cache']) so invalidation is a one-liner.
Pick one fragment whose render cost you can measure — a product grid that runs an expensive query, a sidebar, a "related posts" block. Not the layout, not anything auth-shaped.
{% cache "product_grid_" ~ category.id ~ "_p" ~ page ttl(600) %}
{% include 'partials/product_grid.twig' with { products: fetch_products(category.id, page) } %}
{% endcache %}
Verify it works by timing two requests, then confirm the key exists in Redis (php artisan cache:store won't help — inspect the store directly or via Cache::store('twig_fragments')->has()).
Cached fragments contain rendered markup. Editing partials/product_grid.twig will not invalidate anything. The cheapest defence is a version segment that you bump on deploy:
{% cache "v" ~ app_version ~ "_product_grid_" ~ category.id %}
app_version comes from a Twig global set in the same service provider. This is the single most useful habit with this package — without it you will debug "my template change isn't showing" at least once a sprint.
Wrap the smallest reusable fragment that is expensive. Nesting is legal but the inner fragment's output is baked into the outer fragment's cached string, so the outer TTL silently caps the inner one. Keep fragments flat.
{% cache "v" ~ app_version ~ "_header_" ~ locale ttl(3600) %}
{% include 'layouts/_header.twig' %}
{% endcache %}
{% if %} outside the tag, key inside:
{% if user is not null %}
{% cache "v" ~ app_version ~ "_recommendations_" ~ user.id ttl(300) %}
{% include 'partials/recommendations.twig' %}
{% endcache %}
{% endif %}
Never put csrf_token(), flash messages, or is_granted-dependent chrome inside a cached body. Cache the expensive content, never the session-shaped chrome.
tags(...) requires Symfony\Contracts\Cache\TagAwareCacheInterface. Psr16Adapter is not tag-aware, so swap it for a Symfony tag-aware adapter pointed at the same Redis:
use Symfony\Component\Cache\Adapter\RedisTagAwareAdapter;
$client = $app['redis']->connection('cache')->client(); // \Redis or \Predis\Client
$cache = new RedisTagAwareAdapter($client, 'twig_fragments');
{% cache "product_grid_" ~ category.id tags(['products', 'category_' ~ category.id]) %}
$cache->invalidateTags(['products']); // after an import job
This is the only reliable cross-request invalidation path — see the key-derivation gotcha below for why you should not build invalidation on the literal key string.
In local, either register the extension with an ArrayAdapter (auto-flushed per request) or skip it entirely and keep a {% cache %} stub tag that renders the body. You want template edits to show up instantly during development.
The option syntax is function-call style, not attributes. ttl(300) and tags(['a','b']) are parsed by the extension's token parser at compile time. {% cache "k" ttl=300 %} or tags=['a'] produces a Twig SyntaxError — and because Twig compiles lazily, you'll see it on first render of that template, not at boot. Default TTL when you omit ttl() is 86400 seconds, which surprises people.
Reserved characters in keys. Symfony's PSR-6 CacheItem::validateKey() rejects {}()/\@:. If you pass "frag:product:1", a Symfony adapter can throw InvalidArgumentException at runtime. Stick to [A-Za-z0-9_.-] separators, and smoke-test one key early with the exact adapter and store combination you plan to ship.
Do not build invalidation around the literal key. The extension derives the actual pool key via CacheRuntime::getCacheKey() (template name + arguments are folded in). A Cache::forget('product_grid_12') against your Laravel store will simply miss. Use tags(...), or the deploy-version bump, or a decorator (below).
Decorating the cache is the clean extension point. Rather than subclassing the extension, wrap CacheInterface in your own decorator before handing it to CacheExtension. That's where you prefix keys, log hit/miss, or bridge to Laravel's Cache events:
final class LoggingCache implements CacheInterface
{
public function __construct(private CacheInterface $inner) {}
public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed
{
logger()->debug('twig fragment', ['key' => $key]);
return $this->inner->get($key, $callback, $beta, $metadata);
}
}
Subclassing CacheExtension to override key generation also works, but it pins you to an internal method — the decorator doesn't.
No stampede protection. CacheInterface::get() runs the callback on a miss; concurrent requests for the same cold fragment all render. For a genuinely expensive fragment, either warm it after deploy with an artisan/console command that renders the template for the hot keys, or accept the cost — Symfony's $beta early-expiration argument is available on get() but the Twig tag does not expose it.
Variables set inside the tag don't escape. The fragment compiles into a PHP closure. {% set x = ... %} inside {% cache %} is invisible after {% endcache %}. Read that as a feature: the tag should wrap self-contained includes.
The same key inside a loop collapses to one render. {% for p in products %}{% cache "card_" %} renders the first product for all of them. Always include the iterator identity in the key.
Two caches, two clear commands. php artisan view:clear clears compiled Twig templates; it does nothing for fragments. php artisan cache:clear --store=twig_fragments clears fragments; it does nothing for compiled templates. Confusing the two wastes an afternoon.
Debug by reading generated PHP. With cache enabled, the compiled template lives under storage/framework/views/twig/. Grep it for getCacheKey — you'll see the exact expression and arguments producing your key, which settles most "why isn't this hitting?" questions faster than any log.
Test the hit, not the output. A feature test that asserts identical HTML proves nothing. Assert the second request performs zero extra queries:
DB::enableQueryLog();
$this->get('/category/12')->assertOk();
$first = count(DB::getQueryLog());
$this->get('/category/12')->assertOk();
$this->assertLessThan($first, count(DB::getQueryLog()));
Version lockstep. Last release was 2026-07-03 and several recent releases are "no significant changes" — the package is stable, not abandoned. But it tracks Symfony Contracts, so when you upgrade symfony/cache, bump twig/cache-extra in the same PR and re-run the smoke test on one tag with ttl() and one with tags().
How can I help you explore Laravel packages today?