| name | cache-management |
| description | Implement production-grade caching with cache keys/TTLs/consistency classes per query, SWR (stale-while-revalidate), layered 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. |
Cache Management Skill
Context (Input)
Use this skill when:
- Adding caching to repositories or expensive queries
- Implementing cache invalidation via domain events, Doctrine ODM lifecycle events, or repository fallback hooks
- Defining cache keys, TTLs, and consistency requirements
- Implementing stale-while-revalidate (SWR) pattern
- Testing cache behavior (stale reads, cold start, invalidation)
- Reducing database load with caching
Task (Function)
Implement production-ready caching with proper key design, TTL management, layered explicit invalidation, and comprehensive testing.
Success Criteria:
- Cache policy declared for each query (key, TTL, consistency class)
- Decorator pattern with
CachedXxxRepository wrapping MongoXxxRepository
- Layered invalidation via domain event subscribers, ODM lifecycle listeners, and repository fallback hooks for custom writes that bypass ODM observation
- Best-effort invalidation (try/catch, never fail business operations)
- Comprehensive unit tests for all cache paths
- Cache observability (hit/miss/error logging)
make ci outputs "✅ CI checks successfully passed!"
⚠️ CRITICAL CACHE POLICY
╔═══════════════════════════════════════════════════════════════╗
║ ALWAYS use Decorator Pattern for caching (wrap repositories) ║
║ ALWAYS use CacheKeyBuilder service (prevent key drift) ║
║ ALWAYS invalidate via explicit mapped write signals ║
║ ALWAYS use TagAwareCacheInterface for cache tags ║
║ ALWAYS wrap cache ops in try/catch (best-effort, no failures)║
║ ║
║ ❌ FORBIDDEN: Caching in repository, unmapped invalidation ║
║ ✅ REQUIRED: Decorators + layered explicit invalidation ║
╚═══════════════════════════════════════════════════════════════╝
Non-negotiable requirements:
- Use Decorator Pattern:
CachedXxxRepository wraps MongoXxxRepository
- Use centralized
CacheKeyBuilder service (in Shared/Infrastructure/Cache)
- Invalidate through every reliable write signal:
- Domain event subscribers when events are exposed
- Doctrine ODM lifecycle listeners for managed document changes
- Repository fallback hooks for custom bulk/direct writes that bypass ODM change sets
- Wrap ALL cache operations in try/catch (never fail business operations)
- Use
TagAwareCacheInterface (not CacheInterface) for tag support
- Configure test cache pools with
tags: true in config/packages/test/cache.yaml
- Log cache operations for observability
File Locations (This Codebase)
| 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 |
TL;DR - Cache Management Checklist
Before Implementing Cache:
Architecture Setup:
During Implementation:
Testing:
Before Merge:
Quick Start: Cache in 7 Steps
Step 1: Declare Cache Policy
Before writing code, declare the complete policy:
Step 2: Create CacheKeyBuilder Service
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));
}
public function buildCustomerCollectionKey(array $filters): string
{
ksort($filters);
return $this->build(
'customer',
'collection',
hash('sha256', json_encode($filters, \JSON_THROW_ON_ERROR))
);
}
public function hashEmail(string $email): string
{
return hash('sha256', strtolower($email));
}
}
Step 3: Create Cached Repository Decorator
Location: src/Core/{Entity}/Infrastructure/Repository/Cached{Entity}Repository.php
final class CachedCustomerRepository implements CustomerRepositoryInterface
{
public function __construct(
private CustomerRepositoryInterface $inner, // Wraps MongoCustomerRepository
private TagAwareCacheInterface $cache,
private CacheKeyBuilder $cacheKeyBuilder,
private LoggerInterface $logger
) {}
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
);
} catch (\Throwable $e) {
$this->logCacheError($cacheKey, $e);
return $this->inner->find($id, $lockMode, $lockVersion);
}
}
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);
}
public function delete(Customer $customer): void
{
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);
$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);
$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',
]);
}
}
Step 4: Create Event Subscribers for Invalidation
Location: src/Core/{Entity}/Application/EventSubscriber/{Event}CacheInvalidationSubscriber.php
IMPORTANT: Create ONE subscriber per event (CustomerCreated, CustomerUpdated, CustomerDeleted).
final readonly class CustomerUpdatedCacheInvalidationSubscriber implements DomainEventSubscriberInterface
{
public function __construct(
private TagAwareCacheInterface $cache,
private CacheKeyBuilder $cacheKeyBuilder,
private LoggerInterface $logger
) {}
public function __invoke(CustomerUpdatedEvent $event): void
{
try {
$tagsToInvalidate = $this->buildTagsToInvalidate($event);
$this->cache->invalidateTags($tagsToInvalidate);
$this->logSuccess($event);
} catch (\Throwable $e) {
$this->logError($event, $e);
}
}
public function subscribedTo(): array
{
return [CustomerUpdatedEvent::class];
}
private function buildTagsToInvalidate(CustomerUpdatedEvent $event): array
{
$tags = [
'customer.' . $event->customerId(),
'customer.email.' . $this->cacheKeyBuilder->hashEmail($event->currentEmail()),
'customer.collection',
];
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',
]);
}
}
public function subscribedTo(): array
{
return [CustomerCreatedEvent::class];
}
}
Step 5: Configure services.yaml
Location: config/services.yaml
services:
App\Core\Customer\Infrastructure\Repository\MongoCustomerRepository:
public: true
App\Core\Customer\Infrastructure\Repository\CachedCustomerRepository:
arguments:
$inner: '@App\Core\Customer\Infrastructure\Repository\MongoCustomerRepository'
$cache: '@cache.customer'
App\Core\Customer\Domain\Repository\CustomerRepositoryInterface:
alias: App\Core\Customer\Infrastructure\Repository\CachedCustomerRepository
public: true
App\Core\Customer\Application\EventSubscriber\CustomerCreatedCacheInvalidationSubscriber:
arguments:
$cache: '@cache.customer'
App\Core\Customer\Application\EventSubscriber\CustomerUpdatedCacheInvalidationSubscriber:
arguments:
$cache: '@cache.customer'
App\Core\Customer\Application\EventSubscriber\CustomerDeletedCacheInvalidationSubscriber:
arguments:
$cache: '@cache.customer'
CRITICAL: Always explicitly inject the named cache pool (@cache.customer) instead of relying on autowiring.
Step 6: Configure Cache Pools
Production - config/packages/cache.yaml:
framework:
cache:
app: cache.adapter.redis
default_redis_provider: '%env(resolve:REDIS_URL)%'
pools:
app:
adapter: cache.adapter.redis
default_lifetime: 86400
provider: '%env(resolve:REDIS_URL)%'
cache.customer:
adapter: cache.adapter.redis
default_lifetime: 600
provider: '%env(resolve:REDIS_URL)%'
tags: true
Test - config/packages/test/cache.yaml:
framework:
cache:
app: cache.adapter.array
default_redis_provider: null
pools:
app:
adapter: cache.adapter.array
provider: null
cache.customer:
adapter: cache.adapter.array
provider: null
tags: true
CRITICAL: Test cache pools MUST have tags: true for $cache->invalidateTags() to work!
Step 7: Verify with CI
make ci
The Three Pillars of Cache Management
1. Cache Policies (Keys, TTLs, Consistency)
What: Declare cache configuration before implementation
Key Elements:
- Cache key pattern (namespace + identifier)
- TTL (based on data freshness requirements)
- Consistency class (Strong, Eventual, SWR)
- Cache tags (for invalidation)
Example Policy Decision Matrix:
| Data Type | TTL | Consistency | Invalidation |
|---|
| User profile | 5-10 min | SWR | On update/delete |
| Product catalog | 1 hour | SWR | On product change |
| Configuration | 1 day | Strong | Manual/deployment |
| Search results | 1 min | Eventual | Time-based only |
See: reference/cache-policies.md for complete guide
2. Invalidation Strategies (Explicit, Never Implicit)
What: Explicit cache clearing on write operations
Strategies:
- Write-through: Invalidate immediately after writes
- Tag-based: Batch invalidation using cache tags
- Event-driven: Invalidate via domain events
- ODM lifecycle: Invalidate when managed documents change
- Repository fallback: Invalidate after custom bulk/direct writes that bypass ODM change sets
- Time-based: TTL-only (for static data)
Critical Rule: ALWAYS invalidate explicitly on create/update/delete through every reliable write signal.
$this->repository->save($customer);
$this->cache->invalidateTags(["customer.{$id}"]);
$this->repository->save($customer);
See: reference/invalidation-strategies.md
3. Testing (Stale Reads, Cold Start, Invalidation)
What: Comprehensive test coverage for all cache behaviors
Required Tests:
- ✅ Stale reads after writes
- ✅ Cache warmup on cold start
- ✅ TTL expiration behavior
- ✅ Tag-based invalidation
- ✅ SWR background refresh (if applicable)
See: examples/cache-testing.md
Stale-While-Revalidate (SWR) Pattern
When to use: High-traffic queries that tolerate brief staleness
How it works:
- Serve cached data immediately (even if stale)
- Refresh cache in background
- Return fresh data on next request
Implementation:
public function findById(string $id): ?Customer
{
return $this->cache->get(
"customer.{$id}",
fn($item) => $this->loadFromDatabase($id, $item),
beta: 1.0
);
}
See: reference/swr-pattern.md for complete implementation with background refresh
Integration with Hexagonal Architecture
Domain Layer
- NO caching - Pure business logic
- Domain entities are cache-agnostic
- Domain events should carry data needed for cache invalidation when they are exposed
Application Layer
- Command Handlers publish domain events (not invalidate directly)
- Event Subscribers handle cache invalidation via exposed domain events
Infrastructure Layer
- CachedXxxRepository decorates MongoXxxRepository (read-through caching)
- MongoXxxRepository handles persistence only
- Doctrine ODM listeners handle managed document create/update/delete invalidation
- Repository fallback hooks handle custom writes that bypass ODM change sets
- Domain repository interfaces stay cache-free
Architecture Flow:
┌─────────────────────────────────────────────────────────────┐
│ Command Handler │
│ └─ repository.save(customer) │
│ └─ eventBus.publish(CustomerUpdatedEvent) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ CachedCustomerRepository (Decorator) │
│ └─ inner.save(customer) // delegates to Mongo │
│ └─ no read-through cache mutation in write path │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Layered invalidation │
│ ├─ Domain event subscriber when event is exposed │
│ ├─ ODM listener when managed document changes │
│ └─ Repository fallback for unobservable custom writes │
└─────────────────────────────────────────────────────────────┘
Why Layered Explicit Invalidation?
- Coverage: Domain events cover business signals, ODM covers entity changes, and repository fallbacks cover custom write paths.
- Decoupling: Domain repository interfaces stay cache-free.
- Testability: Invalidation rules and resolvers are tested separately.
- Consistency: All sources use the same cache tags and idempotent invalidation command.
Cache Observability
Log cache operations:
$this->logger->info('Cache miss - loading from database', [
'cache_key' => $cacheKey,
'customer_id' => $id,
'operation' => 'cache.miss',
]);
Track metrics:
- Cache hit rate:
cache.hit.total / (cache.hit.total + cache.miss.total)
- Cache miss rate:
cache.miss.total / total_requests
- Cache operation latency:
cache.operation.duration_ms
- Invalidation frequency:
cache.invalidation.total
See: observability-instrumentation for complete instrumentation patterns
Common Pitfalls
❌ DON'T
- Don't cache without declaring policy first
- Don't cache without TTL
- Don't cache in Domain layer
- Don't use unmapped invalidation or rely on TTL alone
- Don't share cache keys between different queries
- Don't cache sensitive data (PII, passwords, tokens)
- Don't cache without testing all paths
- Don't forget to log cache operations
- Don't put cache invalidation in Domain repository interfaces
- Don't add repository fallback hooks unless the write bypasses ODM change-set observation
- Don't forget to handle email changes (invalidate both old and new email caches)
✅ DO
- Declare complete cache policy before coding
- Use cache tags for flexible invalidation
- Test invalidation explicitly
- Use SWR for read-heavy, stale-tolerant data
- Invalidate on all writes (create, update, delete)
- Log all cache operations
- Monitor cache hit rate in production
- Add observability (logs, metrics)
- Use
__call() magic method for API Platform compatibility
- Wrap ALL cache operations in try/catch
Unit Testing Patterns
Test Structure for Cached Repository
final class CachedCustomerRepositoryTest extends UnitTestCase
{
private CustomerRepositoryInterface&MockObject $innerRepository;
private TagAwareCacheInterface&MockObject $cache;
private CacheKeyBuilder&MockObject $cacheKeyBuilder;
private LoggerInterface&MockObject $logger;
private CachedCustomerRepository $repository;
protected function setUp(): void
{
parent::setUp();
$this->innerRepository = $this->createMock(CustomerRepositoryInterface::class);
$this->cache = $this->createMock(TagAwareCacheInterface::class);
$this->cacheKeyBuilder = $this->createMock(CacheKeyBuilder::class);
$this->logger = $this->createMock(LoggerInterface::class);
$this->repository = new CachedCustomerRepository(
$this->innerRepository,
$this->cache,
$this->cacheKeyBuilder,
$this->logger
);
}
Required Test Cases for Cached Repository
public function testFindUsesCacheWithCorrectKey(): void
{
$customerId = (string) $this->faker->ulid();
$cacheKey = 'customer.' . $customerId;
$customer = $this->createMock(Customer::class);
$this->cacheKeyBuilder->expects($this->once())
->method('buildCustomerKey')
->with($customerId)
->willReturn($cacheKey);
$this->cache->expects($this->once())
->method('get')
->with($cacheKey, $this->isType('callable'), 1.0)
->willReturn($customer);
$result = $this->repository->find($customerId);
self::assertSame($customer, $result);
}
public function testFindFallsBackToDatabaseOnCacheError(): void
{
$customerId = (string) $this->faker->ulid();
$customer = $this->createMock(Customer::class);
$this->cache->expects($this->once())
->method('get')
->willThrowException(new \RuntimeException('Cache unavailable'));
$this->logger->expects($this->once())
->method('error')
->with('Cache error - falling back to database', $this->anything());
$this->innerRepository->expects($this->once())
->method('find')
->willReturn($customer);
$result = $this->repository->find($customerId);
self::assertSame($customer, $result);
}
public function testFindCacheMissLoadsFromDatabase(): void
{
$customerId = (string) $this->faker->ulid();
$cacheItem = $this->createMock(ItemInterface::class);
$cacheItem->expects($this->once())->method('expiresAfter')->with(600);
$cacheItem->expects($this->once())->method('tag')->with(['customer', 'customer.' . $customerId]);
$this->cache->expects($this->once())
->method('get')
->willReturnCallback(fn($key, $callback) => $callback($cacheItem));
}
public function testDeleteInvalidatesCacheAndDelegatesToInnerRepository(): void
{
$customer = $this->createMock(Customer::class);
$customer->method('getUlid')->willReturn('01ABC123');
$customer->method('getEmail')->willReturn('test@example.com');
$this->cache->expects($this->once())
->method('invalidateTags')
->with(['customer.01ABC123', 'customer.email.hash123', 'customer.collection']);
$this->innerRepository->expects($this->once())->method('delete')->with($customer);
$this->repository->delete($customer);
}
public function testDeleteProceedsEvenWhenCacheInvalidationFails(): void
{
$this->cache->method('invalidateTags')
->willThrowException(new \RuntimeException('Redis down'));
$this->logger->expects($this->once())->method('error');
$this->innerRepository->expects($this->once())->method('delete');
$this->repository->delete($customer);
}
Required Test Cases for Event Subscribers
public function testSubscribedToReturnsCorrectEvents(): void
{
$subscribedEvents = $this->subscriber->subscribedTo();
self::assertContains(CustomerUpdatedEvent::class, $subscribedEvents);
}
public function testInvokeInvalidatesCacheWithCorrectTags(): void
{
$event = new CustomerUpdatedEvent(customerId: $customerId, currentEmail: 'test@example.com');
$this->cache->expects($this->once())
->method('invalidateTags')
->with(['customer.' . $customerId, 'customer.email.hash123', 'customer.collection']);
($this->subscriber)($event);
}
public function testInvokeInvalidatesCacheWithEmailChange(): void
{
$event = new CustomerUpdatedEvent(
customerId: $customerId,
currentEmail: 'new@example.com',
previousEmail: 'old@example.com'
);
$this->cache->expects($this->once())
->method('invalidateTags')
->with($this->callback(function ($tags) {
return in_array('customer.email.new_hash', $tags)
&& in_array('customer.email.old_hash', $tags);
}));
($this->subscriber)($event);
}
public function testInvokeLogsErrorWhenCacheInvalidationFails(): void
{
$this->cache->method('invalidateTags')
->willThrowException(new \RuntimeException('Redis connection failed'));
$this->logger->expects($this->once())->method('error');
$this->logger->expects($this->never())->method('info');
($this->subscriber)($event);
}
Integration with Other Skills
Identify queries to cache:
Add observability:
Test cache behavior:
Architecture placement:
Quick Reference
| Pattern | Code Example |
|---|
| Read-through cache | $cache->get($key, fn($item) => $loadFromDb()) |
| Set TTL | $item->expiresAfter(300) (seconds) |
| Set cache tag | $item->tag(['entity', 'entity.id']) |
| Invalidate by tag | $cache->invalidateTags(['entity.id']) |
| Clear all cache | $cache->clear() |
| Build cache key | "{prefix}.{id}" (namespace + identifier) |
| Enable SWR | $cache->get($key, $callback, beta: 1.0) |
Additional Resources
Reference Documentation
Complete Examples
For detailed implementation patterns, invalidation strategies, and test patterns → See supporting files in reference/ and examples/ directories.