| 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 {
();
->(
,
,
(, (, \JSON_THROW_ON_ERROR))
);
}
{
(, ());
}
}
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, = , ? = ): ?
{
= ->cacheKeyBuilder->(() );
{
->cache->(
,
fn (ItemInterface ) => ->(, , , , ),
beta:
);
} (\ ) {
->(, );
->inner->(, , );
}
}
{
= ->cacheKeyBuilder->();
{
->cache->(
,
fn (ItemInterface ) => ->(, , )
);
} (\ ) {
->(, );
->inner->();
}
}
{
->inner->();
}
{
{
->cache->([
,
,
,
]);
->logger->(, [
=> ->(),
=> ,
=> ,
]);
} (\ ) {
->logger->(, [
=> ->(),
=> ->(),
=> ,
]);
}
->inner->();
}
{
->();
->([, ]);
->logger->(, [
=> ,
=> ,
=> ,
]);
->inner->(, , );
}
{
->();
= ->cacheKeyBuilder->();
->([, , ]);
->logger->(, [
=> ,
=> ,
]);
->inner->();
}
{
->logger->(, [
=> ,
=> ->(),
=> ,
]);
}
}
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);
}
}
{
[::];
}
{
= [
. ->(),
. ->cacheKeyBuilder->(->()),
,
];
(->() && ->() !== ) {
[] = . ->cacheKeyBuilder->(->());
}
;
}
{
->logger->(, [
=> ->(),
=> ->(),
=> ->(),
=> ,
=> ,
]);
}
{
->logger->(, [
=> ->(),
=> ->(),
=> ->(),
=> ,
]);
}
}
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' => ,
=> ,
]);
} (\ ) {
->logger->(, [
=> ->(),
=> ->(),
=> ->(),
=> ,
]);
}
}
{
[::];
}
}
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 (
->innerRepository,
->cache,
->cacheKeyBuilder,
->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::(, );
}
{
= () ->faker->();
= ->(::);
->cache->(->())
->()
->( ());
->logger->(->())
->()
->(, ->());
->innerRepository->(->())
->()
->();
= ->repository->();
::(, );
}
{
= () ->faker->();
= ->(::);
->(->())->()->();
->(->())->()->([, . ]);
->cache->(->())
->()
->(fn(, ) => ());
}
{
= ->(::);
->()->();
->()->();
->cache->(->())
->()
->([, , ]);
->innerRepository->(->())->()->();
->repository->();
}
{
->cache->()
->( ());
->logger->(->())->();
->innerRepository->(->())->();
->repository->();
}
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 = (
customerId: ,
currentEmail: ,
previousEmail:
);
->cache->(->())
->()
->(->(function () {
(, )
&& (, );
}));
(->subscriber)();
}
{
->cache->()
->( ());
->logger->(->())->();
->logger->(->())->();
(->subscriber)();
}
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.