Invalidation and Tags
Delete a key, invalidate a tag, and make sure every cached value has a way to be thrown away.
$cache->invalidateTags(['article_7', 'articles']);Writing to a cache is easy. Getting rid of a value when the underlying data changes is the part that produces bug reports. A cached value with no route to removal is a wrong answer with a timer on it.
By key
delete() removes one key. It works when the code that writes the data knows exactly which key is affected. That holds less often than you expect: one article can be cached as a rendered body, a sidebar list, a JSON payload and a sitemap entry, under four keys built in four places.
By tag
A tag is a label attached to an item when it is written. Call $item->tag('article_7') inside the callback, then invalidateTags(['article_7']) later, and every item carrying that label becomes a miss, whatever its key. Tags are the answer to one change affecting many keys. Give each item both a narrow tag for the record and a broad tag for the collection, and the write side only has to know which record changed.
What trips people up
Tagging needs a tag-aware pool. Autowiring TagAwareCacheInterface gives you cache.app.taggable, which wraps cache.app; a pool of your own needs tags: true. And invalidation is not deletion. The pool bumps a version number for the tag so the old items can never be read again; the bytes stay until cache:pool:prune or the backend's own eviction reclaims them.
Example
<?php
// src/Service/ArticleBodyCache.php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
use Twig\Environment;
final class ArticleBodyCache
{
public function __construct(
private TagAwareCacheInterface $cache,
private Environment $twig,
) {
}
public function render(Article $article): string
{
$id = $article->getId();
return $this->cache->get('article_body_' . $id, function (ItemInterface $item) use ($article, $id): string {
$item->expiresAfter(3600);
// Narrow tag for this record, broad tag for the collection.
// tag() takes a string or an array of strings.
$item->tag(['article_' . $id, 'articles']);
return $this->twig->render('article/_body.html.twig', ['article' => $article]);
});
}
public function forget(Article $article): void
{
// Drops every item tagged for this article, in any pool key.
$this->cache->invalidateTags(['article_' . $article->getId()]);
}
public function forgetAll(): void
{
$this->cache->invalidateTags(['articles']);
}
}When to use it
- Drop a cached rendered article the moment an editor saves a change to it.
- Invalidate every cached listing that includes a product when its price changes.
- Clear one pool during a deploy without throwing away the rest of the cache.
More examples
Invalidate after the write, never before
Order matters. Invalidating before the flush opens a window where another request can refill the cache from the old row and keep it for an hour. Flush, then invalidate. The entity came from the route, so no persist() is needed; it is already managed, and flush() writes the change.
<?php
// src/Controller/Admin/ArticleEditController.php
declare(strict_types=1);
namespace App\Controller\Admin;
use App\Entity\Article;
use App\Service\ArticleBodyCache;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
final class ArticleEditController extends AbstractController
{
#[Route('/admin/articles/{id}/title', name: 'app_admin_article_title', methods: ['POST'])]
public function retitle(
Article $article,
Request $request,
EntityManagerInterface $em,
ArticleBodyCache $cache,
): Response {
// POST body, not the query string.
$article->setTitle((string) $request->request->get('title'));
// The row is written here. Nothing is cached yet.
$em->flush();
// Only now is the old cached HTML genuinely out of date.
$cache->forget($article);
return $this->redirectToRoute('app_article_show', ['id' => $article->getId()]);
}
}A separate pool for tags
tags: true keeps tag bookkeeping in the same pool as the data. Pointing it at another pool splits the two, which is what you want when the values are large and the tag checks happen on every read. Both pools must be reachable; losing the tag pool makes every tagged item unreadable.
# config/packages/cache.yaml
framework:
cache:
pools:
article.cache:
adapter: cache.adapter.filesystem
# Items on disk, tag versions in Redis: tag lookups are
# then fast and shared by every machine.
tags: article.tag_pool
article.tag_pool:
adapter: cache.adapter.redisClearing from the console
These are the tools for an incident, and for deploy scripts. cache:pool:clear empties a whole pool, which is heavy-handed but reliable; invalidate-tags is the surgical version. cache:pool:prune belongs in a scheduled task for filesystem pools, which have no eviction of their own.
# List every pool the container knows about.
php bin/console cache:pool:list
# Remove one known key from one pool.
php bin/console cache:pool:delete article.cache article_body_7
# Invalidate tags across every taggable pool, or one named pool.
php bin/console cache:pool:invalidate-tags articles
php bin/console cache:pool:invalidate-tags articles --pool=article.cache
# Empty a pool completely, or every pool except some.
php bin/console cache:pool:clear article.cache
php bin/console cache:pool:clear --all --exclude=article.cache
# Reclaim the space taken by expired and invalidated items.
php bin/console cache:pool:prune
Discussion