Back to skills
SkillHub ClubShip Full StackFull Stack
cache-management
Imported from https://github.com/VilnaCRM-Org/core-service.
Packaged view
This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.
Stars
4
Hot score
81
Updated
March 19, 2026
Overall rating
C3.4
Composite score
3.4
Best-practice grade
B75.6
Install command
npx @skill-hub/cli install vilnacrm-org-core-service-cache-management
Repository
VilnaCRM-Org/core-service
Skill path: .claude/skills/cache-management
Imported from https://github.com/VilnaCRM-Org/core-service.
Open repositoryBest for
Primary workflow: Ship Full Stack.
Technical facets: Full Stack.
Target audience: everyone.
License: Unknown.
Original source
Catalog source: SkillHub Club.
Repository owner: VilnaCRM-Org.
This is still a mirrored public skill entry. Review the repository before installing into production workflows.
What it helps with
- Install cache-management into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
- Review https://github.com/VilnaCRM-Org/core-service before adding cache-management to shared team environments
- Use cache-management for development workflows
Works across
Claude CodeCodex CLIGemini CLIOpenCode
Favorites: 0.
Sub-skills: 0.
Aggregator: No.
Original source / Raw SKILL.md
---
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 `CachedXxxRepository` wrapping `MongoXxxRepository`
- 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 ci` outputs "✅ CI checks successfully passed!"
---
## ⚠️ CRITICAL CACHE POLICY
```text
╔═══════════════════════════════════════════════════════════════╗
║ 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: `CachedXxxRepository` wraps `MongoXxxRepository`
- Use centralized `CacheKeyBuilder` service (in `Shared/Infrastructure/Cache`)
- Invalidate via Domain Event Subscribers (one subscriber per event)
- 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:**
- [ ] 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 `CachedXxxRepository` decorator class
- [ ] Created `CacheKeyBuilder` service (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:**
```php
/**
* 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`
```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`
```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).
```php
/**
* 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:**
```php
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`
```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`:
```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`:
```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
```bash
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](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
```php
// ✅ 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](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](examples/cache-testing.md)
---
## Stale-While-Revalidate (SWR) Pattern
**When to use**: High-traffic queries that tolerate brief staleness
**How it works**:
1. Serve cached data immediately (even if stale)
2. Refresh cache in background
3. Return fresh data on next request
**Implementation**:
```php
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](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**:
```text
┌─────────────────────────────────────────────────────────────┐
│ 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?**
1. **Decoupling**: Repository doesn't know about cache invalidation strategy
2. **Testability**: Easier to test invalidation logic separately
3. **Flexibility**: Can add more invalidation logic without touching repository
4. **Consistency**: Same event triggers multiple side effects (cache, notifications, etc.)
---
## Cache Observability
**Log cache operations**:
```php
$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](../observability-instrumentation/SKILL.md) 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
```php
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
```php
// 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('[email protected]');
$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
```php
// 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: '[email protected]');
$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: '[email protected]',
previousEmail: '[email protected]'
);
$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](../query-performance-analysis/SKILL.md) - Find slow queries
**Add observability**:
- [observability-instrumentation](../observability-instrumentation/SKILL.md) - Cache metrics and logs
**Test cache behavior**:
- [testing-workflow](../testing-workflow/SKILL.md) - Test framework guidance
**Architecture placement**:
- [implementing-ddd-architecture](../implementing-ddd-architecture/SKILL.md) - 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](reference/cache-policies.md)** - TTL selection, consistency classes, policy matrix
- **[Invalidation Strategies](reference/invalidation-strategies.md)** - Write-through, tag-based, event-driven patterns
- **[SWR Pattern](reference/swr-pattern.md)** - Complete stale-while-revalidate implementation
### Complete Examples
- **[Cache Implementation](examples/cache-implementation.md)** - Full repository with caching, invalidation, observability
- **[Cache Testing](examples/cache-testing.md)** - Complete test suite for all cache behaviors
---
**For detailed implementation patterns, invalidation strategies, and test patterns → See supporting files in `reference/` and `examples/` directories.**