php yaml 131 lines · 3 tabs

Cache an Expensive Sales Report in Symfony With a Stamped Key and Invalidation Subscriber

Shared by codesnips Aug 2026
3 tabs
<?php

namespace App\Report;

use App\Repository\OrderRepository;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;

final class SalesReportProvider
{
    public function __construct(
        private readonly TagAwareCacheInterface $salesReportCache,
        private readonly OrderRepository $orders,
    ) {
    }

    public function forRange(\DateTimeImmutable $from, \DateTimeImmutable $to): array
    {
        $filters = [
            'from' => $from->format('Y-m-d'),
            'to'   => $to->format('Y-m-d'),
        ];

        return $this->salesReportCache->get(
            $this->stampKey($filters),
            function (ItemInterface $item) use ($from, $to) {
                $item->expiresAfter(3600);
                $item->tag($this->tagsForRange($from, $to));

                return $this->orders->computeDailyTotals($from, $to);
            }
        );
    }

    private function stampKey(array $filters): string
    {
        ksort($filters);

        return 'sales_report.' . substr(hash('xxh128', json_encode($filters)), 0, 16);
    }

    private function tagsForRange(\DateTimeImmutable $from, \DateTimeImmutable $to): array
    {
        $tags = ['sales'];
        $cursor = $from;

        while ($cursor <= $to) {
            $tags[] = 'sales.day.' . $cursor->format('Y-m-d');
            $cursor = $cursor->modify('+1 day');
        }

        return $tags;
    }
}
3 files · php, yaml Explain with highlit

This snippet shows the read-through caching pattern applied to an expensive aggregate query in Symfony, using the symfony/cache component with tag-based invalidation. The central idea is that the report is computed once, stored under a deterministic key, and served from cache until the underlying data actually changes — at which point a Doctrine event subscriber purges the relevant tags rather than the whole cache.

In SalesReportProvider, the service depends on a TagAwareCacheInterface, which is the tagging-capable variant of the PSR-6 pool. The key is stamped with the query parameters via stampKey(): it hashes the normalized filter array into the key so that two different date ranges never collide, while the same range always resolves to the same entry. The get() callback receives an ItemInterface, and inside it the code sets $item->expiresAfter(3600) as a safety-net TTL and calls $item->tag(...). Tagging is what makes surgical invalidation possible: the item is associated with a coarse sales tag plus a per-day tag, so a single order can drop only the days it touches.

The expiry acts as a backstop; tags are the primary invalidation mechanism. This matters because time-based expiry alone forces a trade-off between staleness and cache churn, whereas tags let the cache stay warm indefinitely and only evict on real writes.

In SalesCacheInvalidationSubscriber, the class implements Doctrine's EventSubscriber and listens to postPersist and postUpdate. Because Doctrine may fire these callbacks mid-transaction, the subscriber does not invalidate immediately — it collects affected tags in a buffer during the entity events and flushes them in postFlush, once the unit of work is committed. This avoids the classic bug where the cache is cleared before the new data is durably written, which would let a concurrent read re-populate the cache with stale rows.

The services.yaml tab wires a dedicated cache pool named cache.sales_report under framework.cache.pools and binds it to the provider, keeping report entries isolated from the app default pool so they can be cleared independently. The trade-off to keep in mind is that tag-aware pools carry a small bookkeeping cost and that invalidation is only as correct as the tag coverage — any write path that bypasses Doctrine events must invalidate manually.


Related snips

Share this code

Here's the card — post it anywhere.

Cache an Expensive Sales Report in Symfony With a Stamped Key and Invalidation Subscriber — share card
Link copied