How to implement a Filesystem based gateway cache in Symfony 5

Anything that doesn't need to be updated constantly, like the content of an article that is requested many times, but isn't modified usually, should be cached. The benefits of caching data in your application are the following ones:

  1. Reduce network calls, especially when your database is hosted on another server and not in the same server where the application backend is currently running.
  2. Avoid recomputations.
  3. Reduce database load (imagine multiple servers requesting the same information over and over again from the same server database, extremely expensive).

In this article, I will explain to you how to easily create your own cache pool using a Filesystem cache adapter in your Symfony 5 project.

Note: as the Symfony documentation mentions, the overhead of filesystem IO often makes this adapter one of the slower choices. If throughput is paramount, the in-memory adapters (Apcu, Memcached, and Redis) or the database adapters (Doctrine and PDO) are recommended.

However, this doesn't mean that it cannot be used as there will be cases where it will be always better to cache stuff locally than requesting information remotely multiple times.

1. Install the Symfony Cache Component

Before proceeding with the implementation, you need to install the Symfony Cache component. The Cache component provides features covering simple to advanced caching needs. It natively implements PSR-6 and the Cache Contracts for the greatest interoperability. It is designed for performance and resiliency, ships with ready-to-use adapters for the most common caching backends.

You can install it with composer with the following command:

composer require symfony/cache

You can visit the official documentation in the Symfony website here, the packagist page or Github repository page here.

2. Create CacheInterface

As the first step, create an empty interface with the name CacheInterface in the src/Utils directory of your Symfony project. The interface looks like this:

<?php
// app/src/Utils\Interfaces\CacheInterface.php

namespace App\Utils\Interfaces;

interface CacheInterface {}

By following this approach, you will be able to switch from cache adapter dynamically (creating a cache pool) without compromising your entire code as they should work with the same pattern. Switching the underlying cache mechanism from this to a Redis or database-based cache won't be a problem. You'll see how this is possible in the next step.

3. Create Filesystem Cache Class

We need to create a new PHP class in the utils directory that will implement the previously created CacheInterface. Inside the constructor, we will instantiate the FilesystemTagAwareAdapter class of the cache component of Symfony to use a tag-based invalidation, since this class offers better performance than wrapping the FilesystemAdapter class inside the TagAwareAdapter class:

<?php

// app/src/Utils/FilesCache.php
namespace App\Utils;

use App\Utils\Interfaces\CacheInterface;
use Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter;

class FilesCache implements CacheInterface 
{    
    /* @var $cache FilesystemTagAwareAdapter */
    public $cache;
    
    public function __construct(string $projectDir, string $env) {
        $this->cache = new FilesystemTagAwareAdapter(
            // a string used as the subdirectory of the root cache directory, where cache
            // items will be stored
            'FilesystemCache',
            // the default lifetime (in seconds) for cache items that do not define their
            // own lifetime, with a value 0 causing items to be stored indefinitely (i.e.
            // until the files are deleted)
            $TTL = 3600,
            // the main cache directory (the application needs read-write permissions on it)
            // if none is specified, a directory is created inside the system temporary directory
            $projectDir . DIRECTORY_SEPARATOR . "var/cache/$env"
        );
    }
}

4. Register CacheInterface

Be sure to register the CacheInterface, providing the class created in step 2 as it will be used as the adapter:

# app/config/services.yaml
services:
    # Register the CacheInterface
    App\Utils\Interfaces\CacheInterface:
        # You may change the cache adapter with a new class here
        class: 'App\Utils\FilesCache'
        # Pass as arguments the required parameters of the root directory
        # of the Sf project and the current environment
        arguments:
            $projectDir: '%kernel.project_dir%'
            $env: '%kernel.environment%'

5. Example of how to use the CacheInterface

You can cache whatever can be stored in files thanks to the key-value approach of all the cache pools. In this example, we'll show you how to cache a whole response (the entire HTML sent to the browser) of the blog_show route of a controller that expects the id and the slug of an article (which will be used as the key of the cached item):

<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

// An example entity
use App\Entity\MyExampleEntity;

// An example repository
use App\Repository\MyExampleEntityRepository;

// Import CacheInterface
use App\Utils\Interfaces\CacheInterface;

class BlogController extends AbstractController
{
     /**
     * Renders the show view of MyExampleEntity.
     *
     * @Route("/blog/{id}/{slug}", name="blog_show")
     */
    public function show(
        int $id, 
        string $slug,
        MyExampleEntityRepository $repo,
        CacheInterface $cache
    ): Response
    {
        /* @var $cache \Symfony\Component\Cache\Adapter\FilesystemTagAwareAdapter */
        $cache = $cache->cache;
        
        // This will be 1 query
        /* @var $entity MyExampleEntity */
        $entity = $repo->find($id);
         
        // Assign cache item with a custom key, in this case we'll use the route and the parameters to create an unique identifier
        $cachedItem = $cache->getItem("blog_show" . $id . $slug);

        // Expire after 30 minutes (1800 seconds)
        $cachedItem->expiresAfter(1800);
        
        // If there's no resource for this item in the cache, create it
        if(!$cachedItem->isHit())
        {
            // Prepare the traditional response in the controller
            // Imagine that the repo will execute 4 queries
            $response = $this->render('blog/show.html.twig', [
                'entity' => $entity,
                'relatedBlogs' => $repo->findRelatedBlogs($entity),
                'latestBlogs' => $repo->findLatestBlogs()
            ]);
            
            // Store the whole response in the cache item
            $cachedItem->set($response);

            // And persist it in the cache
            $cache->save($cachedItem);
        }
        
        // Return the cached item, in this case the response
        // So if the response was cached, you saved 4 queries of overhead
        return $cachedItem->get();
    }
}

When you shouldn't use this approach of caching the whole page as I did in the controller

As I said previously, this is just an example of what you can cache using this Filesystem adapter. You shouldn't use the code of the controller if:

  • A piece of the page is dynamic, for example, a news ticker or a section that lists the latest published articles. Or if you have a custom control panel that only appears to the administrator of the page. As if the page is cached when the admin was logged in, the HTML of the control panel will be cached as well for everyone who accesses that page.
  • Imagine that a visitor adds a product to the shopping cart of his account and the route is cached for the first time by this user (Carlos). When other persons logs in to the application and visit the same page, the same items that Carlos added to the shopping cart will appear on the shopping cart of the new user, just because the whole page has been cached and is being served in the mentioned URL. That's the disadvantage of caching the whole HTML.

You should always be careful with what you cache as sometimes it shouldn't be cached at all.

6. Clearing cache

As the implementation of the FilesCache class stores the cached information inside the symfony-project/var/{env}/ directory, clearing the cache on the environment that you are currently working on will do the trick:

php bin/console cache:clear

Happy coding ❤️!

This could interest you

Become a more social person