Tags and Invalidation
Tag a cache entry with the entities it came from, then clean by tag when one of them changes.
clean($mode = CacheConstants::CLEANING_MODE_ALL, array $tags = [])A cache tag is a label attached to an entry so it can be discarded later without knowing its identifier. You save a computed value under a key and tag it with the entities it was built from. When one of those entities changes, you clean by tag and every entry derived from it goes.
Cleaning by tag
save($data, $identifier, array $tags = [], $lifeTime = null) takes the tags as its third argument. Removal uses clean($mode, array $tags) with a mode constant. In Magento 2.4.9 those constants live in Magento\Framework\Cache\CacheConstants: CLEANING_MODE_ALL, CLEANING_MODE_MATCHING_TAG, CLEANING_MODE_MATCHING_ANY_TAG and others. Up to 2.4.8 the signature named \Zend_Cache::CLEANING_MODE_ALL instead. The string values are identical, so old code keeps working, but write new code against CacheConstants.
Entity tags already exist
Magento's entities publish their own tags through Magento\Framework\DataObject\IdentityInterface, whose single method getIdentities() returns an array of strings. A saved product returns cat_p_42, a CMS block returns cms_b_3 and cms_b_ plus its identifier. Reuse those strings and your data is invalidated by the same save that invalidates Magento's.
The block that outlives the data
An entry saved with no tags is invisible to tag cleaning. It survives until its lifetime expires or someone clears the whole type, and until then it serves the old value. The symptom is one fragment of a page that updates hours late. Check the tags before blaming the cache.
After adding an observer or a di.xml entry, run bin/magento cache:clean config.
Example
<?php
// app/code/Vendor/PriceCache/Observer/CleanTierSummary.php
declare(strict_types=1);
namespace Vendor\PriceCache\Observer;
use Magento\Catalog\Model\Product;
use Magento\Framework\Cache\CacheConstants;
use Magento\Framework\Event\Observer;
use Magento\Framework\Event\ObserverInterface;
use Vendor\PriceCache\Model\Cache\Type\PriceGrid;
class CleanTierSummary implements ObserverInterface
{
public function __construct(
private readonly PriceGrid $cache
) {
}
public function execute(Observer $observer): void
{
$product = $observer->getEvent()->getData('product');
if (!$product instanceof Product || !$product->getId()) {
return;
}
// Product::CACHE_TAG is 'cat_p', so this builds cat_p_42.
$this->cache->clean(
CacheConstants::CLEANING_MODE_MATCHING_TAG,
[Product::CACHE_TAG . '_' . (string)$product->getId()]
);
}
}
// TagScope::clean() adds VENDOR_PRICE_GRID to the tag list,
// so only this cache type's entries for product 42 are removed.
// Register the observer, then run: bin/magento cache:clean configWhen to use it
- Invalidating a cached summary whenever the product it was computed from is saved.
- Making a custom entity participate in cache invalidation by implementing IdentityInterface.
- Clearing every derived entry for a list of product IDs after a bulk import.
More examples
Subscribing to the save
Without this file the observer never runs and the tags are never cleaned, which looks exactly like a caching bug. events.xml is read through the config cache, so run bin/magento cache:clean config after adding it. Placing the file in etc/ rather than etc/frontend/ means it fires for admin saves, API calls and console imports alike.
<!-- app/code/Vendor/PriceCache/etc/events.xml -->
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="catalog_product_save_after">
<observer name="vendor_pricecache_clean_tier_summary"
instance="Vendor\PriceCache\Observer\CleanTierSummary"/>
</event>
</config>Publishing your own identities
Implementing IdentityInterface is how an entity of yours joins the invalidation system. The method is declared without a return type in the framework interface, so keep it that way and document the string[] in a docblock. Anything that caches a derived value can now tag it vendor_faq_q_7 and be cleaned by the same save.
<?php
// app/code/Vendor/Faq/Model/Question.php
declare(strict_types=1);
namespace Vendor\Faq\Model;
use Magento\Framework\DataObject\IdentityInterface;
use Magento\Framework\Model\AbstractModel;
class Question extends AbstractModel implements IdentityInterface
{
public const CACHE_TAG = 'vendor_faq_q';
/**
* @return string[]
*/
public function getIdentities()
{
return [self::CACHE_TAG . '_' . (string)$this->getId()];
}
}
// Output of getIdentities() for question 7:
// ['vendor_faq_q_7']Removing everything derived from several products
MATCHING_ANY_TAG removes an entry that carries any one of the tags; MATCHING_TAG requires all of them. TagScope handles ANY_TAG by looping and cleaning each tag paired with its own type tag, so the scope still holds. Reach for this after a bulk import, when one event covers hundreds of products.
// Inside a service that has PriceGrid injected as $this->cache.
use Magento\Framework\Cache\CacheConstants;
public function cleanProducts(array $productIds): void
{
$tags = [];
foreach ($productIds as $productId) {
$tags[] = 'cat_p_' . (string)$productId;
}
// $tags[] = $x; is shorthand for array_push($tags, $x);
// the two are the same operation.
$this->cache->clean(CacheConstants::CLEANING_MODE_MATCHING_ANY_TAG, $tags);
}
Discussion