| name | cache-management |
| description | 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. |
Cache Management Skill
Context (Input)
Use this skill when:
- Adding caching to repositories or expensive queries
- Implementing cache invalidation via domain events
- 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, event-driven invalidation, and comprehensive testing.
Success Criteria:
- Cache policy declared for each query (key, TTL, consistency class)
- Decorator pattern with
CachedXxxRepositorywrappingMongoXxxRepository - Event-driven invalidation via domain event subscribers
- Best-effort invalidation (try/catch, never fail business operations)
- Comprehensive unit tests for all cache paths
- Cache observability (hit/miss/error logging)
make cioutputs "✅ 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 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:
- Use Decorator Pattern:
CachedXxxRepositorywrapsMongoXxxRepository - Use centralized
CacheKeyBuilderservice (inShared/Infrastructure/Cache) - Invalidate via Domain Event Subscribers (one subscriber per event)
- Wrap ALL cache operations in try/catch (never fail business operations)
- Use
TagAwareCacheInterface(notCacheInterface) for tag support - Configure test cache pools with
tags: trueinconfig/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:
- Identified slow query worth caching (use query-performance-analysis)
- Cache policy declared (key pattern, TTL, consistency class)
- Cache tags defined for invalidation strategy
- Domain events defined for cache invalidation triggers
Architecture Setup:
- Created
CachedXxxRepositorydecorator class - Created
CacheKeyBuilderservice (or extended existing one) - Created cache invalidation event subscribers (one per event)
- Configured services.yaml with explicit cache pool injection
During Implementation:
- Decorator wraps inner repository (not extends)
- CacheKeyBuilder used for all cache keys (prevents drift)
- Cache operations wrapped in try/catch (best-effort)
- Event subscribers use same CacheKeyBuilder for tags
- Logging added for cache hits/misses/errors
- Repository uses
TagAwareCacheInterface(required for tags)
Testing:
- Test cache pool configured with
tags: true - Unit tests for cache invalidation subscribers
- Integration tests for stale reads after writes
- Test: Cache error fallback to database works
Before Merge:
- All cache tests pass
- Cache observability verified (logs present)
- CI checks pass (
make ci) - No cache-related stale data issues
Quick Start: Cache in 7 Steps
Step 1: Declare Cache Policy
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
*/
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));
}
/**
* 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));
}
}
Step 3: Create Cached Repository Decorator
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',
]);
}
}
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).
/**
* 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];
}
}
Step 5: Configure services.yaml
Location: config/services.yaml
services:
# Base repository - used by API Platform for collections
App\Core\Customer\Infrastructure\Repository\MongoCustomerRepository:
public: true
# Cached repository - wraps base repository with caching
App\Core\Customer\Infrastructure\Repository\CachedCustomerRepository:
arguments:
$inner: '@App\Core\Customer\Infrastructure\Repository\MongoCustomerRepository'
$cache: '@cache.customer'
# Alias interface to cached repository for dependency injection
App\Core\Customer\Domain\Repository\CustomerRepositoryInterface:
alias: App\Core\Customer\Infrastructure\Repository\CachedCustomerRepository
public: true
# Cache invalidation event subscribers - explicitly inject cache.customer
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'
# CacheKeyBuilder - auto-registered via App\ namespace
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 # 24 hours
provider: '%env(resolve:REDIS_URL)%'
cache.customer:
adapter: cache.adapter.redis
default_lifetime: 600 # 10 minutes
provider: '%env(resolve:REDIS_URL)%'
tags: true # REQUIRED for TagAwareCacheInterface
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: Must have tags: true for TagAwareCacheInterface!
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
- Time-based: TTL-only (for static data)
Critical Rule: ALWAYS invalidate explicitly on create/update/delete
// ✅ CORRECT
$this->repository->save($customer);
$this->cache->invalidateTags(["customer.{$id}"]);
// ❌ WRONG - Missing invalidation
$this->repository->save($customer);
// Cache now stale until TTL expires!
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 // Enable probabilistic early expiration
);
}
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 carry data needed for cache invalidation
Application Layer
- Command Handlers publish domain events (not invalidate directly)
- Event Subscribers handle cache invalidation via domain events
Infrastructure Layer
- CachedXxxRepository decorates MongoXxxRepository (read-through caching)
- MongoXxxRepository handles persistence only
- Cache invalidation triggered by domain events, NOT in save()
Architecture Flow:
┌─────────────────────────────────────────────────────────────┐
│ Command Handler │
│ └─ repository.save(customer) │
│ └─ eventBus.publish(CustomerUpdatedEvent) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ CachedCustomerRepository (Decorator) │
│ └─ inner.save(customer) // delegates to Mongo │
│ └─ (NO invalidation here!) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ CustomerUpdatedCacheInvalidationSubscriber │
│ └─ cache.invalidateTags([...]) // event-driven! │
└─────────────────────────────────────────────────────────────┘
Why Event-Driven Invalidation?
- Decoupling: Repository doesn't know about cache invalidation strategy
- Testability: Easier to test invalidation logic separately
- Flexibility: Can add more invalidation logic without touching repository
- Consistency: Same event triggers multiple side effects (cache, notifications, etc.)
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 implicit invalidation (use events!)
- 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 invalidate in repository save() method (use event subscribers!)
- 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
// 1. Cache key built correctly
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);
}
// 2. Graceful fallback on cache error
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);
}
// 3. Cache miss loads from database with proper tags
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));
// ... assertions
}
// 4. Delete invalidates cache before deletion
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);
}
// 5. Delete proceeds even on cache failure
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); // Should NOT throw
}
Required Test Cases for Event Subscribers
// 1. Correct events subscribed
public function testSubscribedToReturnsCorrectEvents(): void
{
$subscribedEvents = $this->subscriber->subscribedTo();
self::assertContains(CustomerUpdatedEvent::class, $subscribedEvents);
}
// 2. Cache invalidated with correct tags
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);
}
// 3. Email change invalidates BOTH old and new email caches
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);
}
// 4. Cache failure doesn't throw (best-effort)
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); // Should NOT throw
}
Integration with Other Skills
Identify queries to cache:
- query-performance-analysis - Find slow queries
Add observability:
- observability-instrumentation - Cache metrics and logs
Test cache behavior:
- testing-workflow - Test framework guidance
Architecture placement:
- implementing-ddd-architecture - Layer separation
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
- Cache Policies - TTL selection, consistency classes, policy matrix
- Invalidation Strategies - Write-through, tag-based, event-driven patterns
- SWR Pattern - Complete stale-while-revalidate implementation
Complete Examples
- Cache Implementation - Full repository with caching, invalidation, observability
- Cache Testing - Complete test suite for all cache behaviors
For detailed implementation patterns, invalidation strategies, and test patterns → See supporting files in reference/ and examples/ directories.