Feature Implementation Plan: Comprehensive API Caching Strategy¶
📋 Todo Checklist¶
- [ ] Configure Symfony's Cache component to use a tag-aware adapter (e.g.,
RedisTagAwareAdapter). - [ ] Create a
CacheKeyGeneratorservice to create consistent, hashed keys from API requests. - [ ] Systematically implement caching with tags in all relevant repository methods.
- [ ] Create a Doctrine Event Subscriber to automatically invalidate cache tags when entities are created, updated, or deleted.
- [ ] Write integration tests to verify caching behavior and tag-based invalidation.
- [ ] Final Review and Testing
🔍 Analysis & Investigation¶
Codebase Structure¶
- Configuration:
config/packages/cache.yamlwill be updated to use a tag-aware adapter. - New Services: A new
src/Service/Api/CacheKeyGenerator.phpservice will be created to centralize key generation logic. A newsrc/EventSubscriber/CacheInvalidationSubscriber.phpwill be created to handle invalidation. - Repositories: All repositories that serve API GET requests will be modified to use the new caching pattern.
Current Architecture & Problem¶
- Problem: The previous caching plan was incomplete and did not cover all endpoints. It also proposed a simple key invalidation strategy (in the admin controller) that is not scalable and is prone to errors, as it's easy to forget to invalidate a key.
- Solution: This plan introduces a comprehensive, best-practice caching architecture.
- Cache Key Generation: A dedicated service will create unique and deterministic cache keys from the full request URI, ensuring that every unique filter combination gets its own cache entry.
- Tag-Based Invalidation: We will use cache tags to group related entries. For example, all paginated lists of coffee beans will be tagged with
coffee_bean_list. When any coffee bean is updated, we can invalidate the entirecoffee_bean_listtag at once, which is far more robust and maintainable than trying to delete individual keys.
Target API Endpoints for Caching¶
/api/coffee-beans(List) and/api/coffee-beans/{id}(Detail)/api/filters/metadata/api/locations/countriesand/api/locations/countries/{id}/api/locations/regionsand/api/locations/regions/{id}/api/locations/regions/top/api/varietiesand/api/varieties/{id}/api/varieties/top- All other simple entity list/detail endpoints (Processing Methods, Species, Roast Levels, etc.)
📝 Implementation Plan¶
Prerequisites¶
- Ensure a tag-supporting cache adapter is installed:
composer require symfony/redis-adapter(recommended).
Step-by-Step Implementation¶
-
Configure Tag-Aware Cache
- Files to modify:
config/packages/cache.yaml - Changes needed: Wrap your existing cache adapter with the tag-aware adapter.
- Files to modify:
-
Create
CacheKeyGeneratorService- Files to create:
src/Service/Api/CacheKeyGenerator.php - Changes needed: Create a service that generates a cache key from a
Requestobject.
- Files to create:
-
Systematically Implement Caching in Repositories
- Files to modify: All relevant repositories.
- Changes needed: Inject the
TagAwareCacheInterface $apiCacheand theCacheKeyGenerator. Update all read-only methods to use the new pattern. - Example for a paginated list:
public function findByRequest(Request $request, ...): Paginator { $cacheKey = $this->cacheKeyGenerator->createKey($request); return $this->apiCache->get($cacheKey, function (ItemInterface $item) use ($request, ...) { // Tag the cache item $item->tag(['coffee_bean_list', 'regions_list']); // Tag with all relevant entities // The original query logic goes here... $qb = $this->createQueryBuilder('cb'); // ... $paginator = new Paginator($qb->getQuery()); // Manually iterate to ensure all data is loaded before caching $paginator->getIterator()->getArrayCopy(); return $paginator; }); }
-
Implement Tag-Based Invalidation
- Files to create:
src/EventSubscriber/CacheInvalidationSubscriber.php - Changes needed: Create a Doctrine event subscriber that listens for entity changes and uses a
matchexpression for clean, readable logic.class CacheInvalidationSubscriber implements EventSubscriber { public function __construct(private TagAwareCacheInterface $apiCache) {} public function getSubscribedEvents(): array { return [Events::postUpdate, Events::postPersist, Events::postRemove]; } public function postUpdate(LifecycleEventArgs $args): void { $this->invalidate($args); } public function postPersist(LifecycleEventArgs $args): void { $this->invalidate($args); } public function postRemove(LifecycleEventArgs $args): void { $this->invalidate($args); } private function invalidate(LifecycleEventArgs $args): void { $entity = $args->getObject(); $tagsToInvalidate = match (true) { $entity instanceof CoffeeBean => ['coffee_bean_list', 'top_varieties', 'top_regions'], $entity instanceof Variety => ['varieties_list', 'top_varieties'], $entity instanceof Region => ['regions_list', 'top_regions'], $entity instanceof Country => ['countries_list', 'regions_list'], $entity instanceof ProcessingMethod => ['processing_methods_list'], // ... add a case for every other cached entity ... default => [], }; if (!empty($tagsToInvalidate)) { $this->apiCache->invalidateTags($tagsToInvalidate); } } }
- Files to create:
Testing Strategy¶
- Unit Tests: Write unit tests for the
CacheKeyGeneratorto ensure it produces consistent keys. - Integration Tests:
- Write a test to verify that calling a list endpoint twice results in a cache hit.
- Write a test that:
- Calls a list endpoint (e.g.,
/api/varieties). - Updates a
Varietyentity (e.g., via the entity manager or an admin controller). - Calls the list endpoint again and asserts that the response contains the updated data (proving the cache was invalidated).
- Calls a list endpoint (e.g.,
🎯 Success Criteria¶
- All read-only API endpoints are now cached.
- Cache keys are generated consistently based on the full request URI.
- Updating any entity via any means (admin, crawler, etc.) automatically and correctly invalidates all relevant API caches via tags.
- The API is significantly faster and more resilient to high traffic.
- The caching system is maintainable and easy to extend.