Most of the requests received by our services are queries, and most of these queries are the same. For each request we need to query the database and transform the data for presentation, a lot of work which could be avoided by caching the response. If you developed your service with something resembling CQS, or even a single dispatcher that handles both your queries and commands, you are all set to implement HTTP caching.

Before diving into response caching, let's take a moment to review the gist of a query dispatcher—a command dispatcher is kind of the same except it's not supposed to return a result.

The basic idea of a query dispatcher

A query dispatcher matches a query object with a query handler. The query object contains all the necessary information to perform the query and its handler performs the query, using a repository or a finder, and returns a result: an entity or a collection of entities.

The query object

In order for the caching to work properly, it is crucial that your application has no state. Everything required for the query must be defined inside the query object, any external factor would corrupt the cache.

The following example demonstrates the implementation of a ShowRecipe query, that would be used to fetch a record matching a given identifier:

<?php

// …

class ShowRecipe
{
    /**
     * @var string
     */
    public $id;

    public function __construct(string $id)
    {
        $this->id = $id;
    }
}

Caching responses

Caching responses requires the interaction of multiple components: an action handling the HTTP request, a query dispatcher handling the query, a method turning a query into a response, event subscribers dealing with the response cache, and of course the response cache.

Implementing the show action

The show action uses a ShowRecipe query to fetch one record. It uses the respondToCommand() method defined by its parent class to dispatch the query and return a Response instance, that's where the magic happens.

<?php

// …

class RecipeController extends ControllerAbstract
{
    protected function show(string $id): Response
    {
        return $this->respondToQuery(
            new ShowRecipe($id)
        );
    }
}

Implementing the respondToQuery() method

The respondToQuery() method dispatches a query and returns a Response instance. before and after events are emitted around the dispatching, listeners can use them for many things, in our case the before event is used to provide a response obtained from the cache, while the after event is used to refresh the cache.

Notice that if beforeQuery() provides a response the query is not dispatched and no response is created, we just use the provided response.

<?php

// …

abstract class ControllerAbstract
{
    // …

    protected function respondToQuery(object $query): Response
    {
        $response = $this->beforeQuery($query);

        if (!$response) {
            $result = $this->dispatchQuery($query);
            $response = $this->respond($query, $result);
        }

        return $this->afterQuery($query, $response);
    }

    private function beforeQuery(object $query): ?Response
    {
        $this->eventDispatcher->dispatch(
            BeforeQueryEvent::NAME,
            $event = new BeforeQueryEvent($query)
        );

        return $event->getResponse();
    }

    private function afterQuery(object $query, Response $response): Response
    {
        $this->eventDispatcher->dispatch(
            AfterQueryEvent::NAME,
            $event = new AfterQueryEvent($query, $response)
        );

        return $event->getResponse();
    }
}

Now that we are emitting events, we need a subscriber to provide us with cached responses.

The query subscriber

The query subscriber listens to query events, namely BeforeQueryEvent and AfterQueryEvent. It tries to return a cached response on BeforeQueryEvent, and, if it failed, caches a fresh response on AfterQueryEvent. The subscriber also manages the X-Response-Cache header and set its value to Hit or Miss so that we can determine if the response is coming from the cache or not.

<?php

// …

class QuerySubscriber implements EventSubscriberInterface
{
    // …

    public function beforeQuery(BeforeQueryEvent $event): void
    {
        $query = $event->getQuery();
        $response = $this->cache->retrieve($query);

        if (!$response) {
            return;
        }

        $response->headers->set(self::HEADER_RESPONSE_CACHE, self::CACHE_HIT);

        $event->setResponse($response);
    }

    public function afterQuery(AfterQueryEvent $event): void
    {
        $query = $event->getQuery();
        $response = $event->getResponse();

        if ($response->getStatusCode() !== Response::HTTP_OK ||
            $response->headers->get(self::HEADER_RESPONSE_CACHE) === self::CACHE_HIT) {
            return;
        }

        $this->cache->store($query, $response);
        $response->headers->set(self::HEADER_RESPONSE_CACHE, self::CACHE_MISS);
    }
}

We are done with the query part. We'll learn about the response cache in a bit, but for now let's see how to keep the cache consistent.

Keeping the cache consistent

The cache is only consistent if executing a same query without it would yield the same result. It might be acceptable in some situation to serve stale results, but that's not what we're aiming for here. When a contributor updates a recipe their changes should be reflected immediately.

Just like we use a query dispatcher to fetch data, we use a command dispatcher to create/update/delete data. So, in order to keep our cache fresh, we have to invalidate the corresponding responses. For instance, when a recipe is created responses cached after ListRecipes must be invalidated.

We use another subscriber for this:

<?php

// …

class CommandSubscriber implements EventSubscriberInterface
{
    public function onDispatch(DispatchEvent $event): void
    {
        $command = $event->getCommand();

        $this->cache->remove($command);
    }
}

Now that we've seen how to retrieve/store/remove cached responses, let's see what this mysterious response cache is made of.

The response cache

The response cache is used to retrieve, store, and invalidate responses. We use Symfony's tag aware cache adapter because tagging is crucial for managing our cache. You see every response stored is tagged to be easily invalidated. For instance both the ShowRecipe and ListRecipes queries have recipe tag, so that when a recipe is updated corresponding cached responses are invalidated. They also could use ingredient, so that when ingredients used in recipes are updated the cached responses are also invalidated. The granularity of the cache is up to you.

I usually enumerate basic tags in an interface:

<?php

interface CacheTagEnum
{
    const INGREDIENT = 'ingredient';
    const RECIPE = 'recipe';
    const RESPONSE = 'response';
}

I also define an interface that can be implemented by both query and command classes to specify the tags to use:

<?php

// …

interface HasCacheTags
{
    /**
     * @return string[] The cache tags that should be invalidated after this command.
     */
    public function getCacheTags(): array;
}
<?php

// …

class ShowRecipe implements HasCacheTags
{
    // …

    public function getCacheTags(): array
    {
        return [ CacheTagEnum::RECIPE, CacheTagEnum::INGREDIENT ];
    }
}
<?php

// …

class UpdateRecipe implements HasCacheTags
{
    // …

    public function getCacheTags(): array
    {
        return [ CacheTagEnum::RECIPE ];
    }
}

A response cache implementation

Here is an implementation of the response cache. An hash of the query object is used as cache key, and all cached items are tagged with the same tag so it's easy to invalidate them all at once.

<?php

// …

use Symfony\Component\Cache\Adapter\TagAwareAdapterInterface;
use Symfony\Component\Cache\CacheItem;
use Symfony\Component\HttpFoundation\Response;

class ResponseCache
{
    const CACHE_TAG = CacheTagEnum::RESPONSE;
    const KEY_PREFIX = CacheTagEnum::RESPONSE . '.';

    /**
     * @var TagAwareAdapterInterface
     */
    private $cache;

    /**
     * @var TagResolver
     */
    private $tagResolver;

    public function __construct(TagAwareAdapterInterface $cache, TagResolver $tagResolver)
    {
        $this->cache = $cache;
        $this->tagResolver = $tagResolver;
    }

    public function retrieve(object $query): ?Response
    {
        $item = $this->getCacheItem($query);

        return $item->isHit() ? $item->get() : null;
    }

    public function store(object $query, Response $response): void
    {
        $item = $this->getCacheItem($query);
        $item->tag(array_merge([ self::CACHE_TAG ], $this->resolveTags($query)));
        $item->set($response);

        $this->cache->save($item);
    }

    public function remove(object $command): bool
    {
        $tags = $this->resolveTags($command);

        if (!$tags) {
            return false;
        }

        return $this->cache->invalidateTags($tags);
    }

    public function clear(): void
    {
        $this->cache->invalidateTags([ self::CACHE_TAG ]);
    }

    private function getCacheItem(object $query): CacheItem
    {
        $key = $this->makeKey($query);
        $item = $this->cache->getItem($key);

        return $item;
    }

    private function makeKey(object $query): string
    {
        return self::KEY_PREFIX . $this->hashQuery($query);
    }

    private function hashQuery(object $query): string
    {
        return sha1(serialize($query));
    }

    private function resolveTags(object $queryOrCommand): array
    {
        return ($this->tagResolver)($queryOrCommand);
    }
}

Payoff!

And now we're serving ~90% of the requests from cache. Yay!

Cache graph