Implement production-grade caching with cache keys/TTLs/consistency classes per query, SWR (stale-while-revalidate), explicit invalidation, HTTP cache headers, and comprehensive testing for stale reads and cache warmup. Use when adding caching to queries, implementing cache invalidation, configuring HTTP caching, or ensuring cache consistency and performance.
Use this skill when:
Implement production-ready caching with proper key design, TTL management, event-driven invalidation, HTTP cache headers, and comprehensive testing.
Success Criteria:
CachedXxxRepository wrapping MongoXxxRepositorymake ci outputs "✅ CI checks successfully passed!"╔═══════════════════════════════════════════════════════════════╗
║ ALWAYS use Decorator Pattern for caching (wrap repositories) ║
║ ALWAYS use CacheKeyBuilder service (prevent key drift) ║
║ ALWAYS invalidate via Domain Events (decouple from business) ║
║ ALWAYS use TagAwareCacheInterface for cache tags ║
║ ALWAYS wrap cache ops in try/catch (best-effort, no failures)║
║ ALWAYS use Marker Interface for auto-binding cache pools ║
║ ALWAYS process invalidation async (AP from CAP theorem) ║
║ ║
║ ❌ FORBIDDEN: Caching in repository, implicit invalidation ║
║ ✅ REQUIRED: Decorator pattern, event-driven invalidation ║
╚═══════════════════════════════════════════════════════════════╝
Cache invalidation follows AP from CAP theorem - we prioritize:
Trade-off: Brief staleness is acceptable over blocking writes.
Implementation:
Non-negotiable requirements:
CachedXxxRepository wraps MongoXxxRepositoryCacheKeyBuilder service (in Shared/Infrastructure/Cache)_instanceofTagAwareCacheInterface (not CacheInterface) for tag supporttags: true in config/packages/test/cache.yamlThese are example locations based on the Codely/Hexagonal structure used in VilnaCRM services. Adapt the bounded context (
User,OAuth, etc.) to your feature.
| Component | Typical Location |
|---|---|
| CacheKeyBuilder | src/Shared/Infrastructure/Cache/CacheKeyBuilder.php |
| CachedXxxRepository | src/{Context}/{Bounded}/Infrastructure/Repository/CachedXxxRepository.php |
| Base repository (inner) | src/{Context}/{Bounded}/Infrastructure/Repository/*Repository.php |
| Marker interface | src/{Context}/{Bounded}/Application/EventSubscriber/*CacheInvalidationSubscriberInterface.php |
| Invalidation subscriber | src/{Context}/{Bounded}/Application/EventSubscriber/*CacheInvalidationSubscriber.php |
| Cache pool config | config/packages/cache.yaml |
| Test cache config | config/packages/test/cache.yaml |
| Service wiring / aliases | config/services.yaml |
| HTTP cache tests | tests/Integration/*HttpCacheTest.php |
| Unit tests | tests/Unit/** |
| Integration tests (optional) | tests/Integration/** |
Before Implementing Cache:
Architecture Setup:
CachedXxxRepository decorator classCacheKeyBuilder service (or extended existing one)services.yaml with _instanceof for auto-binding cache poolsDuring Implementation:
TagAwareCacheInterface (required for tags)Testing:
tags: trueBefore Merge:
make ci)Before writing code, declare the complete policy:
/**
* Cache Policy for Customer By ID Query
*
* Key Pattern: customer.{id}
* TTL: 600s (10 minutes)
* Consistency: Stale-While-Revalidate
* Invalidation: Via domain events (CustomerCreated/Updated/Deleted)
* Tags: [customer, customer.{id}]
* HTTP Cache: Cache-Control: max-age=600, public, s-maxage=600
* Notes: Read-heavy operation, tolerates brief staleness
*/
Location: src/Shared/Infrastructure/Cache/CacheKeyBuilder.php
final readonly class CacheKeyBuilder
{
public function __construct(private SerializerInterface $serializer)
{
}
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', $this->serializer->encode($filters, JsonEncoder::FORMAT))
);
}
/**
* Hash email consistently (lowercase + SHA256)
* - Lowercase normalization (email case-insensitive)
* - SHA256 hashing (fixed length, prevents key length issues)
*/
public function hashEmail(string $email): string
{
return hash('sha256', strtolower($email));
}
}
Location: src/{Context}/{Entity}/Infrastructure/Repository/Cached{Entity}Repository.php
final class CachedCustomerRepository implements CustomerRepositoryInterface
{
public function __construct(
private CustomerRepositoryInterface $inner, // Wraps base repository
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
);
} catch (\Throwable $e) {
$this->logCacheError($cacheKey, $e);
return $this->inner->find($id, $lockMode, $lockVersion);
}
}
public function save(Customer $customer): void
{
$this->inner->save($customer);
// NO cache invalidation here - handled by domain event subscribers
}
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 logCacheError(string $cacheKey, \Throwable $e): void
{
$this->logger->error('Cache error - falling back to database', [
'cache_key' => $cacheKey,
'error' => $e->getMessage(),
'operation' => 'cache.error',
]);
}
}
Location: src/{Context}/{Entity}/Application/EventSubscriber/{Entity}CacheInvalidationSubscriberInterface.php
Purpose: Enables automatic cache pool injection via _instanceof in services.yaml.
<?php
declare(strict_types=1);
namespace App\Core\Customer\Application\EventSubscriber;
use App\Shared\Domain\Bus\Event\DomainEventSubscriberInterface;
/**
* Marker interface for customer cache invalidation subscribers.
*
* Used to auto-bind the customer cache pool via Symfony _instanceof configuration.
*/
interface CustomerCacheInvalidationSubscriberInterface extends DomainEventSubscriberInterface
{
}
Location: src/{Context}/{Entity}/Application/EventSubscriber/{Event}CacheInvalidationSubscriber.php
IMPORTANT: Create ONE subscriber per event. Implement the marker interface.
/**
* Customer Updated Event Cache Invalidation Subscriber
*
* ARCHITECTURAL DECISION: Processed via async queue (ResilientAsyncEventBus)
* This subscriber runs in Symfony Messenger workers. Exceptions propagate to
* DomainEventMessageHandler which catches, logs, and emits failure metrics.
* We follow AP from CAP theorem (Availability + Partition tolerance over Consistency).
*/
final readonly class CustomerUpdatedCacheInvalidationSubscriber implements
CustomerCacheInvalidationSubscriberInterface
{
public function __construct(
private TagAwareCacheInterface $cache,
private CacheKeyBuilder $cacheKeyBuilder,
private LoggerInterface $logger
) {}
public function __invoke(CustomerUpdatedEvent $event): void
{
$tagsToInvalidate = $this->buildTagsToInvalidate($event);
$this->cache->invalidateTags($tagsToInvalidate);
$this->logSuccess($event);
}
/** @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',
];
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', [
'event_id' => $event->eventId(),
'email_changed' => $event->emailChanged(),
'operation' => 'cache.invalidation',
'reason' => 'customer_updated',
]);
}
}
CRITICAL: Use _instanceof with the marker interface for auto-binding cache pools.