[Part 2/2] - Symfony 3 with Redis Cache


In the previous video we setup a Dockerised Symfony and Redis stack, and sent through a few test requests to ensure our cache was being hit as expected.

However, in the real world, it's my opinion that it's not a good idea to use the cache directly from a controller action.

What I am about to cover is one such way I might use the cache. You are free to disagree with this methodology, and of course, also should adapt or change this to meet your own needs. As ever, please feel free to share alternative approaches in the comments below.

Moving To A Service

Rather than grabbing the cache directly inside a Controller action, instead, I would extract this out to a service.

I've found that Symfony's autowiring struggles with injecting the cache directly into a controller action. This is why we ended up using the service locator approach in the previous example:

$this->get('cache.app')->...

This is fine from a controller action. As our custom controller extends Symfony's Controller, we can call get which exists as a method inside the base controller / Symfony's Controller, and we are good to grab any configured service.

As a side note, the way that get works is changing behind the scenes in Symfony 3.4. Here's the Symfony 3.3.x version, and here's the Symfony 3.4+ version. You don't need to know this, but it's interesting all the same.

But what if we extract all this out to a service? What do we inject?

Let's create a new service:

<?php

// src/AppBundle/Service/CacheExample.php

namespace AppBundle\Service;

// this won't work
// so don't copy / paste
class CacheExample
{
    public function get()
    {
        $cacheKey = md5('123');

        $cachedItem = $this->get('cache.app')->getItem($cacheKey);

        if (false === $cachedItem->isHit()) {
            $cachedItem->set($cacheKey, 'some value');
            $this->get('cache.app')->save($cachedItem);
        }

        return $cachedItem;
    }
}

There's some strangeness going on here.

Firstly, we aren't really using the cache in any meaningful way. Everything is hardcoded - the key, and value.

Next, we're still trying to call $this->get('cache.app'). This won't work.

Finally, we return the $cachedItem. This would be weird, as we've now tied whatever is using this CacheExample service to working with an interface we don't control (Symfony\Component\Cache\Adapter\AdapterInterface). This would be a potentially unusual behaviour from the perspective of other developers on our team.

I'm going to keep the hardcoded approach for the moment, and get started fixing the critical problem: injecting the cache service.

Injecting The Cache

We know the service ID is cache.app.

We can look up information on this service by debugging the container:

# run from the PHP container

www-data@php:~/dev$ php bin/console debug:container cache.app

Information for Service "cache.app"
===================================

 ---------------- --------------------------------------------------------
  Option           Value
 ---------------- --------------------------------------------------------
  Service ID       cache.app
  Class            Symfony\Component\Cache\Adapter\TraceableAdapter
  Tags             cache.pool
                   cache.pool (clearer: cache.default_clearer, unlazy: 1)
  Public           yes
  Synthetic        no
  Lazy             no
  Shared           yes
  Abstract         no
  Autowired        no
  Autoconfigured   no
 ---------------- --------------------------------------------------------

As we are using Symfony 3.3 here we can, in theory, just inject whatever we need and let Symfony figure out what and how.

<?php

// src/AppBundle/Service/CacheExample.php

// this still won't work, so hold your horses
// on copy / pasting

namespace AppBundle\Service;

use Symfony\Component\Cache\Adapter\TraceableAdapter;

class CacheExample
{
    /**
     * @var TraceableAdapter
     */
    private $cache;

    public function __construct(TraceableAdapter $cache)
    {
        $this->cache = $cache;
    }

    public function get()
    {
        $cacheKey = md5('123');

        $cachedItem = $this->cache->getItem($cacheKey);

        if (false === $cachedItem->isHit()) {
            $cachedItem->set($cacheKey, 'some value');
            $this->cache->save($cachedItem);
        }

        return $cachedItem;
    }
}

The change here being the constructor injection of TraceableAdapter (odd name), and then updating the calls from:

$this->get('cache.app')

to

$this->cache

Next, let's update the controller to use this service instead of spewing all our logic into the action directly:

<?php

namespace AppBundle\Controller;

use AppBundle\Service\CacheExample;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class DefaultController extends Controller
{
    /**
     * @Route("/", name="homepage")
     */
    public function indexAction(CacheExample $cacheExample)
    {
        return $this->render('default/index.html.twig', [
            'cache' => [
                'hit' => $cacheExample->get()->isHit(),
            ]
        ]);
    }
}

We can still call isHit because our get method returns an instance of AdapterInterface, which is odd, but will be addressed shortly.

Now when we browse to our home page we see:

AutowiringFailedException
Cannot autowire service "AppBundle\Service\CacheExample": argument "$cache" of method "__construct()" references class "Symfony\Component\Cache\Adapter\TraceableAdapter" but no such service exists. Try changing the type-hint to "Psr\Cache\CacheItemPoolInterface" instead.

Oh dear.

It looks like we used the wrong type hint. That's odd, because we copied that directly from the dumped service config.

Being completely honest here I haven't figured out why this is. The only documentation I can find that hints at why this is can be found here. Please do leave a comment below if you know more.

Anyway, let's update as instructed:

<?php

// src/AppBundle/Service/CacheExample.php

namespace AppBundle\Service;

use Psr\Cache\CacheItemPoolInterface;

class CacheExample
{
    /**
     * @var CacheItemPoolInterface
     */
    private $cache;

    public function __construct(CacheItemPoolInterface $cache)
    {
        $this->cache = $cache;
    }

And at this point yes, our service should be working.

We can validate this by clearing the cache and refreshing the page. Rather than clear the full app cache, let's just clear the cache for our particular cache pool:

www-data@php:~/dev$ php bin/console cache:pool:clear cache.app

 // Clearing cache pool: cache.app

 [OK] Cache was successfully cleared.

How did we know the cache pool was called cache.app?

Remember back to our config:

# app/config/config.yml

framework:
    # ...
    cache:
        app: cache.adapter.redis

Actually cache.app is the default provided cache pool when using the Symfony framework.

You may be thinking:

I don't want to cache everything under one big central cache. My needs are more complex.

No problem, let's set up some cache pools.

Cache Pools

First, we need to add in some extra config:

# app/config/config.yml

framework:
    # ...
    cache:
        app: cache.adapter.redis
        default_redis_provider: "redis://%redis.host%:%redis.port%"
        pools:
            app.cache.widget:
                public: true
                adapter: cache.adapter.redis
                default_lifetime: 100 # 100 seconds
            app.cache.sprocket:
                adapter: cache.adapter.redis
                default_lifetime: 604800 # 1 week in seconds

Note, we still have the default cache.app.

Now we have two new cache pools. You can call them what you like. app.cache.x seems to work for me.

I have exposed the app.cache.widget service publicly. I've kept the app.cache.sprocket service as a private (default false, if public not set). Using Symfony 3.3 onwards you probably don't need to expose your services publicly in most cases. If you'd like to understand public / private services further, please read this page of the docs.

Each cache pool can use a different caching adapter. We're using Redis, because that's what this series is about :)

I set a default_lifetime for each pool, as if you don't, the default lifetime is 0, which means forever / until cleared.

Now, how do we use one of these new cache pools inside our service?

www-data@php:~/dev$ php bin/console debug:container app.cache.widget

Information for Service "app.cache.widget"
==========================================

 ---------------- --------------------------------------------------------
  Option           Value
 ---------------- --------------------------------------------------------
  Service ID       app.cache.widget
  Class            Symfony\Component\Cache\Adapter\TraceableAdapter
  Tags             cache.pool (default_lifetime: 100)
                   cache.pool (clearer: cache.default_clearer, unlazy: 1)
  Public           yes
  Synthetic        no
  Lazy             no
  Shared           yes
  Abstract         no
  Autowired        no
  Autoconfigured   no
 ---------------- --------------------------------------------------------

Well, we already know that's not going to work. And besides, we need to somehow differentiate between the cache.app, and app.cache.xxx pools.

To do this we will need to be more specific.

It's important to realise that at this point, our app will still be working. Symfony won't magically start using our new cache pools. We need to explicitly define which pool we want to use. Until we do, our CacheExample will continue to use cache.app.

Let's clear all the caches, and start again:

www-data@php:~/dev$ php bin/console cache:clear

 // Clearing the cache for the dev environment with debug true

 [OK] Cache for the "dev" environment (debug=true) was successfully cleared.

And checking Redis:

127.0.0.1:6379> keys *
(empty list or set)

By default, we still use cache.app, so after a page refresh:

127.0.0.1:6379> keys *
1) "fQVL7uG9-N:202cb962ac59075b964b07152d234b70"

Cool.

Now, let's switch to using app.cache.widget. To do this we need to explicitly inject the app.cache.widget via the CacheExample constructor. To do this in Symfony 3.3 we can do:

# app/config/services.yml

services:

    # ... default stuff here

    AppBundle\Service\CacheExample:
        arguments:
            $cache: "@app.cache.widget"

Note that $cache is the name we gave to the variable for the argument in our CacheExample:

class CacheExample
{
    public function __construct(CacheItemPoolInterface $cache)

Ok, refreshing the page once more:

127.0.0.1:6379> keys *
1) "fQVL7uG9-N:202cb962ac59075b964b07152d234b70"
2) "3D2RLNfcTI:202cb962ac59075b964b07152d234b70"

Entry 1 is our entry from cache.app. This won't be removed simply because we are no longer using that cache. It will just sit there forever / until cleared.

The second entry is our new entry for app.cache.widget pool.

We can clear this:

www-data@php:~/dev$ php bin/console cache:pool:clear app.cache.widget

 // Clearing cache pool: app.cache.widget

 [OK] Cache was successfully cleared.

And boom, it's gone.

127.0.0.1:6379> keys *
1) "fQVL7uG9-N:202cb962ac59075b964b07152d234b70"

Again, we cleared a specific pool, so other pools remain unaffected.

Let's now use the private app.cache.sprocket pool:

# app/config/services.yml

services:

    # ... default stuff here

    AppBundle\Service\CacheExample:
        arguments:
            $cache: "@app.cache.sprocket"

And a refresh:

127.0.0.1:6379> keys *
1) "G-EJEX9Bkk:202cb962ac59075b964b07152d234b70"
2) "fQVL7uG9-N:202cb962ac59075b964b07152d234b70"

Cool, now let's clear both the app.cache.sprocket and cache.app pools.

www-data@php:~/dev$ php bin/console cache:pool:clear app.cache.sprocket cache.app

 // Clearing cache pool: app.cache.sprocket

 // Clearing cache pool: cache.app

 [OK] Cache was successfully cleared.

Looking good.

Wrapping Up

We've a couple of further things to fix before we're done.

We're still returning $cachedItem, and we can't set anything other than a single hardcoded key / value pair.

Let's fix this:

<?php

// src/AppBundle/Service/CacheExample.php

namespace AppBundle\Service;

use Psr\Cache\CacheItemPoolInterface;

class CacheExample
{
    /**
     * @var CacheItemPoolInterface
     */
    private $cache;

    public function __construct(CacheItemPoolInterface $cache)
    {
        $this->cache = $cache;
    }

    public function get(string $key)
    {
        $cacheKey = md5($key);

        $cachedItem = $this->cache->getItem($cacheKey);

        if (false === $cachedItem->isHit()) {
            // imagine we do some expensive task here
            // such as calling a remote API
            // $expensiveValue = $this->injectedApiService->get("some-url/etc");
            $expensiveValue = rand(1,100);

            $cachedItem->set($cacheKey, $expensiveValue);
            $this->cache->save($cachedItem);
        }

        // either way, this is now the result we want
        return $cachedItem->get();
    }
}

Here we pass in a string to get. If that string / key exists, then the value is returned. If not, we do the expensive operation (calling a remote API is a good example of this) and save the result. Either way, we return the result.

For reference, I would inject another service to do the actual lookup. This way I would have a cache on top of the service, but independent. It's more layers / indirection, but it's more flexible.

Note that we only do the call if the cache is not hit.

<?php

namespace AppBundle\Controller;

use AppBundle\Service\CacheExample;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class DefaultController extends Controller
{
    /**
     * @Route("/", name="homepage")
     */
    public function indexAction(CacheExample $cacheExample)
    {
        return $this->render('default/index.html.twig', [
            'result' => $cacheExample->get('some-value'),
        ]);
    }
}

We would also need to update default/index.html.twig:

{% extends 'base.html.twig' %}

{% block body %}
    {{ result }}
{% endblock %}

We no longer get access to whether the cache was hit. We could achieve this, but do you ever need to show an end user if they are hitting the cache? Not in my opinion.

However, that may be useful in an admin backend. This is where separating the cache from the API call / expensive operation potentially becomes more useful.

Episodes

# Title Duration
1 Get Started With Symfony Cache 03:48
2 [Part 1/2] - Symfony 3 with Redis Cache 07:03
3 [Part 2/2] - Symfony 3 with Redis Cache 08:41