Skip to content

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 CacheKeyGenerator service 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.yaml will be updated to use a tag-aware adapter.
  • New Services: A new src/Service/Api/CacheKeyGenerator.php service will be created to centralize key generation logic. A new src/EventSubscriber/CacheInvalidationSubscriber.php will 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.
    1. 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.
    2. 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 entire coffee_bean_list tag 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/countries and /api/locations/countries/{id}
  • /api/locations/regions and /api/locations/regions/{id}
  • /api/locations/regions/top
  • /api/varieties and /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

  1. Configure Tag-Aware Cache

    • Files to modify: config/packages/cache.yaml
    • Changes needed: Wrap your existing cache adapter with the tag-aware adapter.
      framework:
          cache:
              pools:
                  api.cache:
                      adapter: cache.adapter.redis # Or your preferred adapter
                      tags: true # Enable the tag-aware adapter
                      default_lifetime: 3600 # 1 hour
      
  2. Create CacheKeyGenerator Service

    • Files to create: src/Service/Api/CacheKeyGenerator.php
    • Changes needed: Create a service that generates a cache key from a Request object.
      public function createKey(Request $request): string
      {
          // Use the full request URI, which includes all query parameters
          $uri = $request->getRequestUri();
          // Use a fast hashing algorithm to keep the key short and safe
          return 'api_request_' . hash('sha1', $uri);
      }
      
  3. Systematically Implement Caching in Repositories

    • Files to modify: All relevant repositories.
    • Changes needed: Inject the TagAwareCacheInterface $apiCache and the CacheKeyGenerator. 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;
          });
      }
      
  4. 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 match expression 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);
              }
          }
      }
      

Testing Strategy

  • Unit Tests: Write unit tests for the CacheKeyGenerator to 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:
      1. Calls a list endpoint (e.g., /api/varieties).
      2. Updates a Variety entity (e.g., via the entity manager or an admin controller).
      3. Calls the list endpoint again and asserts that the response contains the updated data (proving the cache was invalidated).

🎯 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.