Caching Without Lying
Measure before you cache, protect against stampedes, and never serve a stale answer a user can tell is wrong.
$cache->get($key, $callback, beta: 1.0);A cache trades correctness for speed. The craft is in choosing trades the user cannot notice.
Measure first
Cache what is both slow and frequent. The profiler tells you which is which: the Doctrine panel gives query count and time per request, the Cache panel gives reads, hits, misses and writes per pool. A hit rate near zero means the key varies more than the data does. A high hit rate on something that took two milliseconds means you added a dependency for nothing.
Stampedes
A stampede is what happens when a popular key expires under load: every in-flight request misses at once and all of them run the expensive callback. The contracts' get() defends against this with probabilistic early expiration. The $beta argument, 1.0 by default, makes one request recompute slightly before the deadline while the others keep reading the old value. Higher values recompute earlier; 0 disables it; INF forces a recompute now.
The answer no one should see
Three rules hold up. Never cache under a key that omits something the value depends on, including the user id. Never cache an authorisation decision. And after a user's own write, show them the real data: invalidate on the write path, or read around the cache on the page that follows it. A stale list elsewhere is a shrug; their own missing change is a bug report.
Example
<?php
// src/Service/DashboardCounters.php
declare(strict_types=1);
namespace App\Service;
use App\Entity\User;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final class DashboardCounters
{
public function __construct(
private TagAwareCacheInterface $cache,
) {
}
/**
* @return array{open: int, overdue: int}
*/
public function forUser(User $user): array
{
// The user id is part of the key. A shared key here would show
// one person another person's numbers.
$key = 'dashboard_counters_' . $user->getId();
return $this->cache->get($key, function (ItemInterface $item) use ($user): array {
$item->expiresAfter(120);
$item->tag(['dashboard_' . $user->getId()]);
return $this->count($user);
}, beta: 1.0); // one request refreshes early; the rest read the old value
}
public function invalidate(User $user): void
{
// Called from the write path, after the change is committed.
$this->cache->invalidateTags(['dashboard_' . $user->getId()]);
}
/**
* @return array{open: int, overdue: int}
*/
private function count(User $user): array
{
return ['open' => 4, 'overdue' => 1];
}
}
// $counters->forUser($user);
// Output: ['open' => 4, 'overdue' => 1]When to use it
- Decide between caching a page fragment and fixing the query that makes it slow.
- Stop a popular cached report from being rebuilt by fifty concurrent requests at once.
- Make sure a user who just submitted a form sees their own change rather than a cached count.
More examples
Forcing a refresh without an empty window
Reach for this when a nightly command or an admin action should rebuild a cached value on purpose. Passing INF keeps the old value readable right up to the moment the new one replaces it, which a delete cannot do. Note that the beta argument only matters for items with an expiry.
<?php
// src/Service/ReportCache.php
declare(strict_types=1);
namespace App\Service;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
final class ReportCache
{
public function __construct(
private CacheInterface $cache,
) {
}
public function monthly(bool $refresh = false): string
{
return $this->cache->get(
'report_monthly',
function (ItemInterface $item): string {
$item->expiresAfter(900);
return $this->build();
},
// INF always runs the callback and overwrites the value.
// delete() then get() also recomputes, but leaves the key
// empty in between, so concurrent requests all miss.
$refresh ? \INF : 1.0,
);
}
private function build(): string
{
return 'report-2026-09';
}
}
// $reports->monthly();
// Output: 'report-2026-09'Reading around the cache after the user's own write
This is read-your-writes. A two-minute stale counter is harmless until the person looking at it is the one who just changed the underlying data, at which point it looks broken. Invalidating on the write path costs one cache call and removes the whole class of complaint.
<?php
// src/Controller/TaskController.php
declare(strict_types=1);
namespace App\Controller;
use App\Entity\Task;
use App\Service\DashboardCounters;
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 TaskController extends AbstractController
{
#[Route('/tasks', name: 'app_task_create', methods: ['POST'])]
public function create(
Request $request,
EntityManagerInterface $em,
DashboardCounters $counters,
): Response {
$task = new Task();
// POST body: $request->request. The query string is $request->query.
$task->setTitle((string) $request->request->get('title'));
// persist() schedules the insert, flush() writes it.
$em->persist($task);
$em->flush();
// Their own numbers must be right on the page they land on.
$counters->invalidate($this->getUser());
$this->addFlash('success', 'Task created.');
return $this->redirectToRoute('app_dashboard');
}
}
// POST /tasks returns 302 to /dashboard, where the counters are recomputed.Seeing the cost before caching it
Do this before and after. A cache that cannot be shown to have helped is pure risk: it adds a stale-data failure mode and an invalidation obligation for no measured gain. The Cache panel is also how you catch a key that includes something it should not, such as a timestamp, which produces writes on every request and almost no hits.
# Reproduce the slow page against the dev environment, then open the
# profiler for that exact request:
# /_profiler/latest -> Performance, Doctrine and Cache panels
#
# What to read:
# Doctrine panel : query count and total query time
# Cache panel : reads, hits, misses, writes, per pool
# Performance : where the wall time actually went
# Check the hit rate is real in production-like conditions, not on a
# warm single-process dev server:
php bin/console cache:pool:clear cache.app
# Confirm a pool is wired to the adapter you think it is:
php bin/console debug:config framework cache
Discussion