Implement production-grade caching with cache keys/TTLs/consistency classes per query, SWR (stale-while-revalidate), explicit invalidation, and comprehensive testing for stale reads and cache warmup. Use when adding caching to queries, implementing cache invalidation, or ensuring cache consistency and performance.
Use this skill when:
Implement production-ready caching with proper key design, TTL management, event-driven invalidation, and comprehensive testing.
Success Criteria:
CachedXxxRepository wrapping MongoXxxRepositorymake ci outputs "✅ CI checks successfully passed!"╔═══════════════════════════════════════════════════════════════╗
║ ALWAYS use Decorator Pattern for caching (wrap repositories) ║
║ ALWAYS use CacheKeyBuilder service (prevent key drift) ║
║ ALWAYS invalidate via Domain Events (decouple from business) ║
║ ALWAYS use TagAwareCacheInterface for cache tags ║
║ ALWAYS wrap cache ops in try/catch (best-effort, no failures)║
║ ║
║ ❌ FORBIDDEN: Caching in repository, implicit invalidation ║
║ ✅ REQUIRED: Decorator pattern, event-driven invalidation ║
╚═══════════════════════════════════════════════════════════════╝
Non-negotiable requirements:
CachedXxxRepository wraps MongoXxxRepositoryCacheKeyBuilder service (in Shared/Infrastructure/Cache)TagAwareCacheInterface (not CacheInterface) for tag supporttags: true in config/packages/test/cache.yaml| Component | Location |
|---|---|
| CacheKeyBuilder | src/Shared/Infrastructure/Cache/CacheKeyBuilder.php |
| CachedCustomerRepository | src/Core/Customer/Infrastructure/Repository/CachedCustomerRepository.php |
| Created Invalidation Sub | src/Core/Customer/Application/EventSubscriber/CustomerCreatedCacheInvalidationSubscriber.php |
| Updated Invalidation Sub | src/Core/Customer/Application/EventSubscriber/CustomerUpdatedCacheInvalidationSubscriber.php |
| Deleted Invalidation Sub | src/Core/Customer/Application/EventSubscriber/CustomerDeletedCacheInvalidationSubscriber.php |
| Cache Pool Config | config/packages/cache.yaml |
| Test Cache Config | config/packages/test/cache.yaml |
| Services Config | config/services.yaml |
| Repository Unit Tests | tests/Unit/Customer/Infrastructure/Repository/CachedCustomerRepositoryTest.php |
| Subscriber Unit Tests | tests/Unit/Customer/Application/EventSubscriber/*CacheInvalidation*Test.php |
Before Implementing Cache:
Architecture Setup:
CachedXxxRepository decorator classCacheKeyBuilder service (or extended existing one)During Implementation:
TagAwareCacheInterface (required for tags)Testing:
tags: trueBefore Merge:
make ci)Before writing code, declare the complete policy:
/**
* Cache Policy for Customer By ID Query
*
* Key Pattern: customer.{id}
* TTL: 600s (10 minutes)
* Consistency: Stale-While-Revalidate
* Invalidation: Via domain events (CustomerCreated/Updated/Deleted)
* Tags: [customer, customer.{id}]
* Notes: Read-heavy operation, tolerates brief staleness
*/
Location: src/Shared/Infrastructure/Cache/CacheKeyBuilder.php
final readonly class CacheKeyBuilder
{
public function build(string $namespace, string ...$parts): string
{
return $namespace . '.' . implode('.', $parts);
}
public function buildCustomerKey(string $customerId): string
{
return $this->build('customer', $customerId);
}
public function buildCustomerEmailKey(string $email): string
{
return $this->build('customer', 'email', $this->hashEmail($email));
}
/**
* Build cache key for collections (filters normalized + hashed)
* @param array<string, string|int|float|bool|array|null> $filters
*/
public function buildCustomerCollectionKey(array $filters): string
{
ksort($filters); // Normalize key order
return $this->build(
'customer',
'collection',
hash('sha256', json_encode($filters, \JSON_THROW_ON_ERROR))
);
}
/**
* Hash email consistently (lowercase + SHA256)
* - Lowercase normalization (email case-insensitive)
* - SHA256 hashing (fixed length, prevents key length issues)
*/
public function hashEmail(string $email): string
{
return hash('sha256', strtolower($email));
}
}
Location: src/Core/{Entity}/Infrastructure/Repository/Cached{Entity}Repository.php
/**
* Cached Customer Repository Decorator
*
* Responsibilities:
* - Read-through caching with Stale-While-Revalidate (SWR)
* - Cache key management via CacheKeyBuilder
* - Graceful fallback to database on cache errors
* - Delegates ALL persistence operations to inner repository
*
* Cache Invalidation:
* - Handled by *CacheInvalidationSubscriber classes via domain events
* - This class only reads from cache, never invalidates (except delete)
*/
final class CachedCustomerRepository implements CustomerRepositoryInterface
{
public function __construct(
private CustomerRepositoryInterface $inner, // Wraps MongoCustomerRepository
private TagAwareCacheInterface $cache,
private CacheKeyBuilder $cacheKeyBuilder,
private LoggerInterface $logger
) {}
/**
* Proxy all other method calls to inner repository
* Required for API Platform's collection provider compatibility
* @param array<int, mixed> $arguments
*/
public function __call(string $method, array $arguments): mixed
{
return $this->inner->{$method}(...$arguments);
}
public function find(mixed $id, int $lockMode = 0, ?int $lockVersion = null): ?Customer
{
$cacheKey = $this->cacheKeyBuilder->buildCustomerKey((string) $id);
try {
return $this->cache->get(
$cacheKey,
fn (ItemInterface $item) => $this->loadCustomerFromDb($id, $lockMode, $lockVersion, $cacheKey, $item),
beta: 1.0 // Enable Stale-While-Revalidate
);
} catch (\Throwable $e) {
$this->logCacheError($cacheKey, $e);
return $this->inner->find($id, $lockMode, $lockVersion); // Graceful fallback
}
}
public function findByEmail(string $email): ?Customer
{
$cacheKey = $this->cacheKeyBuilder->buildCustomerEmailKey($email);
try {
return $this->cache->get(
$cacheKey,
fn (ItemInterface $item) => $this->loadCustomerByEmail($email, $cacheKey, $item)
);
} catch (\Throwable $e) {
$this->logCacheError($cacheKey, $e);
return $this->inner->findByEmail($email);
}
}
public function save(Customer $customer): void
{
$this->inner->save($customer);
// NO cache invalidation here - handled by domain event subscribers
}
public function delete(Customer $customer): void
{
// Delete is special: invalidate BEFORE deletion (best-effort)
try {
$this->cache->invalidateTags([
"customer.{$customer->getUlid()}",
"customer.email.{$this->cacheKeyBuilder->hashEmail($customer->getEmail())}",
'customer.collection',
]);
$this->logger->info('Cache invalidated before customer deletion', [
'customer_id' => $customer->getUlid(),
'operation' => 'cache.invalidation',
'reason' => 'customer_deleted',
]);
} catch (\Throwable $e) {
$this->logger->error('Cache invalidation failed during deletion - proceeding anyway', [
'customer_id' => $customer->getUlid(),
'error' => $e->getMessage(),
'operation' => 'cache.invalidation.error',
]);
}
$this->inner->delete($customer);
}
private function loadCustomerFromDb(mixed $id, int $lockMode, ?int $lockVersion, string $cacheKey, ItemInterface $item): ?Customer
{
$item->expiresAfter(600); // 10 minutes TTL
$item->tag(['customer', "customer.{$id}"]);
$this->logger->info('Cache miss - loading customer from database', [
'cache_key' => $cacheKey,
'customer_id' => $id,
'operation' => 'cache.miss',
]);
return $this->inner->find($id, $lockMode, $lockVersion);
}
private function loadCustomerByEmail(string $email, string $cacheKey, ItemInterface $item): ?Customer
{
$item->expiresAfter(300); // 5 minutes TTL
$emailHash = $this->cacheKeyBuilder->hashEmail($email);
$item->tag(['customer', 'customer.email', "customer.email.{$emailHash}"]);
$this->logger->info('Cache miss - loading customer by email', [
'cache_key' => $cacheKey,
'operation' => 'cache.miss',
]);
return $this->inner->findByEmail($email);
}
private function logCacheError(string $cacheKey, \Throwable $e): void
{
$this->logger->error('Cache error - falling back to database', [
'cache_key' => $cacheKey,
'error' => $e->getMessage(),
'operation' => 'cache.error',
]);
}
}
Location: src/Core/{Entity}/Application/EventSubscriber/{Event}CacheInvalidationSubscriber.php
IMPORTANT: Create ONE subscriber per event (CustomerCreated, CustomerUpdated, CustomerDeleted).
/**
* Customer Updated Event Cache Invalidation Subscriber
* Handles email change edge case (both old and new email caches)
*/
final readonly class CustomerUpdatedCacheInvalidationSubscriber implements DomainEventSubscriberInterface
{
public function __construct(
private TagAwareCacheInterface $cache,
private CacheKeyBuilder $cacheKeyBuilder,
private LoggerInterface $logger
) {}
public function __invoke(CustomerUpdatedEvent $event): void
{
// Best-effort: don't fail business operation if cache is down
try {
$tagsToInvalidate = $this->buildTagsToInvalidate($event);
$this->cache->invalidateTags($tagsToInvalidate);
$this->logSuccess($event);
} catch (\Throwable $e) {
$this->logError($event, $e);
}
}
/** @return array<class-string> */
public function subscribedTo(): array
{
return [CustomerUpdatedEvent::class];
}
/** @return array<string> */
private function buildTagsToInvalidate(CustomerUpdatedEvent $event): array
{
$tags = [
'customer.' . $event->customerId(),
'customer.email.' . $this->cacheKeyBuilder->hashEmail($event->currentEmail()),
'customer.collection',
];
// CRITICAL: If email changed, invalidate previous email cache too!
if ($event->emailChanged() && $event->previousEmail() !== null) {
$tags[] = 'customer.email.' . $this->cacheKeyBuilder->hashEmail($event->previousEmail());
}
return $tags;
}
private function logSuccess(CustomerUpdatedEvent $event): void
{
$this->logger->info('Cache invalidated after customer update', [
'customer_id' => $event->customerId(),
'email_changed' => $event->emailChanged(),
'event_id' => $event->eventId(),
'operation' => 'cache.invalidation',
'reason' => 'customer_updated',
]);
}
private function logError(CustomerUpdatedEvent $event, \Throwable $e): void
{
$this->logger->error('Cache invalidation failed after customer update', [
'customer_id' => $event->customerId(),
'event_id' => $event->eventId(),
'error' => $e->getMessage(),
'operation' => 'cache.invalidation.error',
]);
}
}
Simpler subscriber for Created/Deleted events:
final readonly class CustomerCreatedCacheInvalidationSubscriber implements DomainEventSubscriberInterface
{
public function __construct(
private TagAwareCacheInterface $cache,
private CacheKeyBuilder $cacheKeyBuilder,
private LoggerInterface $logger
) {}
public function __invoke(CustomerCreatedEvent $event): void
{
try {
$this->cache->invalidateTags([
'customer.' . $event->customerId(),
'customer.email.' . $this->cacheKeyBuilder->hashEmail($event->customerEmail()),
'customer.collection',
]);
$this->logger->info('Cache invalidated after customer creation', [
'customer_id' => $event->customerId(),
'event_id' => $event->eventId(),
'operation' => 'cache.invalidation',
'reason' => 'customer_created',
]);
} catch (\Throwable $e) {
$this->logger->error('Cache invalidation failed after customer creation', [
'customer_id' => $event->customerId(),
'event_id' => $event->eventId(),
'error' => $e->getMessage(),
'operation' => 'cache.invalidation.error',
]);
}
}
/** @return array<class-string> */
public function subscribedTo(): array
{
return [CustomerCreatedEvent::class];
}
}
Location: config/services.yaml