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

Cache Extra Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

Reality check first

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:

  1. composer.json — is twig/twig pinned to 3.x? cache-extra v3 requires Twig 3.
  2. Your Twig environment factory — usually an AppServiceProvider binding or a small package. This is where the extension gets registered.
  3. 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.

Minimal setup

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.

First use case

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()).

Implementation Patterns

Namespaced keys with a deploy version

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.

One cache tag per logical block, not per page

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

Per-user fragments belong behind a condition

{% 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.

Tag-aware invalidation when you need it

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.

Dev vs. prod behaviour

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.

Gotchas and Tips

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().

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.
gmostafa/php-graphql-client
xeviant/paystack
snowsoft/faker
meditationsteps/blogetc
animalyoung/laravel-influxdb
onix-solutions/sms-telera
lonely-lights/prosetta
oxygen/data
oxygen/core
jengo/base
xiloryx/log-collector
a21ns1g4ts/filament-stripe
la-php/laravel-admin
artisan-build/community-prompts
vleroy/smart
ayles-software/laravel-australian-public-holidays
cboxdk/laravel-id-spatie
cboxdk/laravel-id-billing
cboxdk/laravel-id-billing-bridge
simple-as-fuck/php-performance-log