Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Using a Caching Layer & Proving its Worth

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

Whenever we make something more performant, we often also make our code more complex. So, was the property-caching trick we just used worth it? Maybe... but I'm going to revert it.

Remove the property caching logic and just return $this->calculateUserActivityText($user):

... lines 1 - 10
class AppExtension extends AbstractExtension
{
... lines 13 - 26
public function getUserActivityText(User $user): string
{
return $this->calculateUserActivityText($user);
}
... lines 31 - 49
}

And... we don't need the $userStatuses property anymore:

... lines 1 - 10
class AppExtension extends AbstractExtension
{
... lines 13 - 14
private $userStatuses = [];
... lines 16 - 55
}

We could stop here and say: this spot is not worth optimizing. Or, we can try a different solution - like using a real caching layer. After all, this label probably won't change very often... and it's probably not critical that the label changes at the exact moment a user adds enough comments to get to the next level. Caching could be an easy win.

Adding Caching

Back in AppExtension, autowire Symfony's cache object by adding an argument type-hinted with CacheInterface - the one from Symfony\Contracts\Cache. I'll press Alt+Enter and select "Initialize fields" to make PhpStorm create a new property with this name and set it in the constructor:

... lines 1 - 7
use Symfony\Contracts\Cache\CacheInterface;
... lines 9 - 12
class AppExtension extends AbstractExtension
{
... line 15
private $cache;
public function __construct(CommentHelper $commentHelper, CacheInterface $cache)
{
... line 20
$this->cache = $cache;
}
... lines 23 - 59
}

Down in the method, let's first create a cache key that's specific to each user. How about: $key = sprintf('user_activity_text_'.and then $user->getId():

... lines 1 - 12
class AppExtension extends AbstractExtension
{
... lines 15 - 30
public function getUserActivityText(User $user): string
{
$key = sprintf('user_activity_text_'.$user->getId());
... lines 34 - 39
}
... lines 41 - 59
}

Wow, I just realized that my sprintf here is totally pointless.

Then, return $this->cache->get() and pass this $key. If that item exists in the cache, it will return immediately:

... lines 1 - 12
class AppExtension extends AbstractExtension
{
... lines 15 - 30
public function getUserActivityText(User $user): string
{
$key = sprintf('user_activity_text_'.$user->getId());
return $this->cache->get($key, function(CacheItemInterface $item) use ($user) {
... lines 36 - 38
});
}
... lines 41 - 59
}

Otherwise, it will execute this callback function, pass us a CacheItemInterface object and our job will be to return the value that should be stored in cache.

Hmm... I need the $user object inside here. Add use then $user to bring it into scope. Then return $this->calculateUserActivityText($user):

... lines 1 - 12
class AppExtension extends AbstractExtension
{
... lines 15 - 30
public function getUserActivityText(User $user): string
{
$key = sprintf('user_activity_text_'.$user->getId());
return $this->cache->get($key, function(CacheItemInterface $item) use ($user) {
... lines 36 - 37
return $this->calculateUserActivityText($user);
});
}
... lines 41 - 59
}

I think it's probably safe to cache this value for one hour: that's long enough, but not so long that we need to worry about adding a system to manually invalidate the cache. Set the expiration with $item->expiresAfter(3600):

... lines 1 - 12
class AppExtension extends AbstractExtension
{
... lines 15 - 30
public function getUserActivityText(User $user): string
{
$key = sprintf('user_activity_text_'.$user->getId());
return $this->cache->get($key, function(CacheItemInterface $item) use ($user) {
$item->expiresAfter(3600);
return $this->calculateUserActivityText($user);
});
}
... lines 41 - 59
}

So... does this help? Of course it will! More importantly, because we decided we don't need to worry about adding more complexity to invalidate the cache, it's probably a big win! But let's find out for sure.

Move over and refresh. Boo - 500 error. We're in the prod environment... and I forgot to rebuild the cache:

php bin/console cache:clear

And:

php bin/console cache:warmup

Profiling with Cache

Refresh again. And... profile! I'll name this one: [Recording] Show page real cache. Open up the call graph: https://bit.ly/sf-bf-real-caching.

This time things look way better. But let's not trust it: go compare the original profile - before we even did property caching - to this new one: https://bit.ly/sf-bf-compare-real-cache.

Wow. The changes are significant... and there's basically no downside to the changes we made. Even our memory went down! You can also compare this to the property caching method: https://bit.ly/sf-bf-compare-prop-real-caching. Yea... it's way better

And really, this is no surprise: fully caching things will... of course be faster! The question is how much faster? And if adding caching means that you also need to add a cache invalidation system, is that performance boost worth it? Since we don't need to worry about invalidation in this case, it was totally worth it.

Next: let's find & solve a classic N+1 query problem. The final solution might not be what you traditionally expect.

Leave a comment!

3
Login or Register to join the conversation
Kiuega Avatar

Hi team! I love this way of caching items! In the video you set up a cache on the user_activity_text, referenced by the user ID. You invalidate this cache after 3600 seconds.

Couldn't we instead create a Doctrine listener, which, when a user adds / deletes a comment, then we invalidate the 'user_activity_text' cache referenced by that user?

In general, would managing the timeout manually using Doctrine listeners be a good solution?

I also appreciate the fact that you put forward the fact of creating a cache key per user, I was wondering if this was relevant or if we should have a cache for all users!

Reply

Hey Kiuega

Those are great questions. I think you can implement that listener and do what you say, it should work but you must be aware that Doctrine's listeners are not triggered when you run a custom query, i.e. when you don't create your query via the QueryBuilder. Another thing to consider is code clarity. It may not be obvious for other developers on your team how the cache it's cleared out. Perhaps you would prefer to manually clear the cache directly on the remove/add comment endpoints.

Cheers!

1 Reply
Kiuega Avatar

Thank you very much for your response and these tips! I will apply them for my project! :)

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This tutorial can be used to learn how to profile any app - including Symfony 5.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "blackfire/php-sdk": "^1.20", // v1.20.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // v1.8.0
        "doctrine/doctrine-bundle": "^1.6.10|^2.0", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^1.3|^2.0", // v2.0.0
        "doctrine/orm": "^2.5.11", // v2.6.4
        "phpdocumentor/reflection-docblock": "^3.0|^4.0", // 4.3.2
        "sensio/framework-extra-bundle": "^5.4", // v5.5.1
        "symfony/console": "4.3.*", // v4.3.10
        "symfony/dotenv": "4.3.*", // v4.3.10
        "symfony/flex": "^1.9", // v1.18.7
        "symfony/form": "4.3.*", // v4.3.10
        "symfony/framework-bundle": "4.3.*", // v4.3.9
        "symfony/http-client": "4.3.*", // v4.3.10
        "symfony/property-access": "4.3.*", // v4.3.10
        "symfony/property-info": "4.3.*", // v4.3.10
        "symfony/security-bundle": "4.3.*", // v4.3.10
        "symfony/serializer": "4.3.*", // v4.3.10
        "symfony/twig-bundle": "4.3.*", // v4.3.10
        "symfony/validator": "4.3.*", // v4.3.10
        "symfony/webpack-encore-bundle": "^1.6", // v1.7.2
        "symfony/yaml": "4.3.*", // v4.3.10
        "twig/extensions": "^1.5" // v1.5.4
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.2", // 3.2.2
        "easycorp/easy-log-handler": "^1.0.7", // v1.0.9
        "fzaninotto/faker": "^1.8", // v1.8.0
        "symfony/browser-kit": "4.3.*", // v4.3.10
        "symfony/css-selector": "4.3.*", // v4.3.10
        "symfony/debug-bundle": "4.3.*", // v4.3.10
        "symfony/maker-bundle": "^1.13", // v1.14.3
        "symfony/monolog-bundle": "^3.0", // v3.5.0
        "symfony/phpunit-bridge": "^5.0", // v5.0.3
        "symfony/stopwatch": "4.3.*", // v4.3.10
        "symfony/var-dumper": "4.3.*", // v4.3.10
        "symfony/web-profiler-bundle": "4.3.*" // v4.3.10
    }
}