<?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;
}
}
<?php
namespace App\EventListener;
use App\Entity\Order;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final class SalesCacheInvalidationSubscriber implements EventSubscriber
{
private array $pendingTags = [];
public function __construct(private readonly TagAwareCacheInterface $salesReportCache)
{
}
public function getSubscribedEvents(): array
{
return [Events::postPersist, Events::postUpdate, Events::postFlush];
}
public function postPersist(LifecycleEventArgs $args): void
{
$this->collect($args->getObject());
}
public function postUpdate(LifecycleEventArgs $args): void
{
$this->collect($args->getObject());
}
public function postFlush(PostFlushEventArgs $args): void
{
if ([] === $this->pendingTags) {
return;
}
$this->salesReportCache->invalidateTags(array_values(array_unique($this->pendingTags)));
$this->pendingTags = [];
}
private function collect(object $entity): void
{
if (!$entity instanceof Order) {
return;
}
$this->pendingTags[] = 'sales';
$this->pendingTags[] = 'sales.day.' . $entity->getPlacedAt()->format('Y-m-d');
}
}
framework:
cache:
app: cache.adapter.redis
pools:
cache.sales_report:
adapter: cache.adapter.redis
tags: true
default_lifetime: 3600
services:
_defaults:
autowire: true
autoconfigure: true
App\Report\SalesReportProvider:
arguments:
$salesReportCache: '@cache.sales_report'
App\EventListener\SalesCacheInvalidationSubscriber:
arguments:
$salesReportCache: '@cache.sales_report'
tags:
- { name: doctrine.event_subscriber, connection: default }
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
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
Rails.application.configure do
config.after_initialize do
Bullet.enable = true
Bullet.alert = false
Bullet.bullet_logger = true
Bullet.console = true
N+1 query detection with Bullet gem
json.array! @posts do |post|
json.cache! ['v1', post], expires_in: 1.hour do
json.id post.id
json.title post.title
json.excerpt post.excerpt
json.published_at post.published_at
Fragment caching for expensive JSON serialization
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
class Comment < ApplicationRecord
belongs_to :post, touch: true
belongs_to :author, class_name: "User"
validates :body, presence: true, length: { maximum: 10_000 }
Granular Cache Invalidation with touch: true
Share this code
Here's the card — post it anywhere.