DEV Community

Cover image for Boosting up PHP-project with cache
Elijah Zobenko
Elijah Zobenko

Posted on

Boosting up PHP-project with cache

The growth of the project and its load can be a real challenge for the developer. The website begins responding with a delay and the question of scaling is being raised more often. There are many effective solutions to increase the stability of the project and the load capability, and one of the most basic is caching.

Caching is the saving of data in highly accessible places temporarily so that their retrieval is performed faster than from the source. The most common example of using a cache is getting data from a database. When a product is first received from the database, for example, it is stored in the cache for a certain time, so each subsequent request to this product will not disturb the database, since the data will be received from another storage.

What are the approaches?

There are many approaches to caching. You can check the list of PHP-compatible tools on the php-cache page. However, the most common ones are:

  • APCu
  • Array
  • Memcached
  • Redis

Let's look at their differences and features.

APCu

One of the most common and easy-to-configure caching tools. Saves the data into RAM (and also knows how to cache intermediate code, but that's a completely different story ...). To get started with APCu, you need to make sure that it is installed. To do that, run the following command on the command line:

php -i | grep 'apc.enabled'
# We're expecting to see:
# apc.enabled => On => On
Enter fullscreen mode Exit fullscreen mode

Another way to check it: create a file index.php and put the phpinfo() function call in it. Make sure that you have a web server configured for the directory you are using and open the script in the browser via the server address. We are interested in the APCu section, if there is an APCu Support: Enabled item inside it, then everything is fine, we can move on.

image

If you don't have APCu installed, you can do it in the following way:

  1. Launch a terminal window (Linux/macOS) or a command prompt (Windows. Enter "cmd" in the search)
  2. Run the command: pecl install apcu apcu_bc
  3. Open the php.ini configuration file via any text editor and make sure that the following lines are present:

    # Windows
    extension=php_apcu.dll
    extension=php_apcu_bc.dll
    
    apc.enabled=1
    apc.enable_cli=1
    
    #Linux / MacOS
    extension="apcu.so"
    extension="apc.so"
    
    apc.enabled=1
    apc.enable_cli=1
    
  4. If there are no specified lines, add them and save the configuration file

  5. Repeat the check for the installed APCu

To use this caching approach, we will need the basic functions. Here is an example of their application.

$cacheKey = 'product_1';
$ttl = 600; // 10 minutes.

// Checking APCu availability
$isEnabled = apcu_enabled();

// Checks if there is data in the cache by key
$isExisted = apcu_exists($cacheKey);

// Saves data to the cache. Returns true if successful
// The $ttl argument determines how long the cache will be stored (seconds)
$isStored = apcu_store($cacheKey, ['name' => 'Demo product'], $ttl);

// Retrieves data from the cache by key. If not, returns false
$data = apcu_fetch($cacheKey);

// Deletes data from the cache by key
$isDeleted = apcu_delete($cacheKey);

var_dump([
    'is_enabled'   => $isEnabled,
    'is_existed'   => $isExisted,
    'is_stored'    => $isStored,
    'is_deleted'   => $isDeleted,
    'fetched_data' => $data,
]);
Enter fullscreen mode Exit fullscreen mode

Any cache works on the principle of key-value storage, which means that the stored data is stored with a special key, which is accessed. In this case, the key is stored in the $cacheKey variable.

Important! This approach only works in the website mode, i.e. when running from the command line, you will not receive data from the cache, and everything that you have saved to it will be cleared when the script completes. However, this will not cause any errors.

Array-cache

A simpler, but not always applicable, caching method. If the APCu saves data and makes it available for subsequent execution by all processes, then the Array cache stores it only for the request being processed.

What does it mean? Imagine that you have a page with user comments. One user can leave several messages, and when we collect an array of this data, we do not want to fetch the same user from the database several times. What we can do is set the received data to an array so that if there is one, we don't make a request again. This principle is very simple and just as easy to implement. Let's write a class that will perform such a save.

class CustomArrayCache
{
    /**
     * Array is static and private
     * – private – so that it can be accessed only from the
     * methods of the class
     * – static – so that the property is available in all instances
     */
    private static array $memory = [];

    // Method for storing data in memory
    public function store(string $key, $value): bool
    {
        self::$memory[$key] = $value;

        return true;
    }

    // Method for getting data from memory
    public function fetch(string $key)
    {
        return self::$memory[$key] ?? null;
    }

    // Method for deleting data from memory
    public function delete(string $key): bool
    {
        unset(self::$memory[$key]);

        return true;
    }

    // Method for checking the availability of key data
    public function exists(string $key): bool
    {
        return array_key_exists($key, self::$memory);
    }
}
Enter fullscreen mode Exit fullscreen mode

This approach is very simple, but due to its limitations, it is rarely used. However, it is useful to know about it.

Memcached & Redis

The most advanced caching approaches. They require a server (daemon) running separately. From PHP, we connect to this server at the address and port. The configuration of these solutions is more complicated than setting up an APCu, but the data storage method is very similar (RAM). Their most important advantages are:

  • Isolation from PHP. Separate services are responsible for the cache;
  • Clustering capability. If the load on your project is very high, clustering of caching services will help to cope with it;

In this article, we will not go into the details of configuring Memcached and Redis. At this stage, we need to remember that if the load is very high– we should look towards these solutions since they have a good potential for scaling.

PSR-16 Standard

PSR has two standards dedicated to caching: PSR-6 (normal caching interface) and PSR-16 (Simple caching interface) – we will focus on PSR-16.

This standard offers a special interface (CacheInterface) that classes that perform the caching function can satisfy. According to it, such classes should implement the following methods:

  • get($key, $default) - Getting data from the cache. The second argument passes the value that will be returned if this data is missing.;
  • set($key, $value, $ttl = null) - Saving data to the cache. As we saw earlier, the third parameter is passed the storage time in seconds. If left empty (null), the default value from the cache configuration will be substituted;
  • delete($key) - Deletes key data;
  • clear() - Clears the entire storage;
  • getMultiple($keys, $default) - Allows you to get data for several keys at once;
  • setMultiple($values, $ttl = null) - Allows you to record multiple values at once. As $value we pass an associative array, where the key is $key for the cache, and the value is the data to save;
  • deleteMultiple($keys) - Deletes data for multiple keys;
  • has($key) - Checks the availability of key data.

As you can see, the interface is very simple, and even the functions that we considered in the example for APCu are enough to write your cache service following PSR-16. But why is it necessary?

The main advantages of compliance with PSR standards:

  • They are supported by the most popular libraries;
  • Many PHP programmers adhere to PSR and will easily get used to your code;
  • Thanks to the interface, we can easily change the service used to any other that supports PSR-16.

Let's take a closer look at the last point and its benefits.

Usage of PSR-16 libraries

Libraries that create a "wrapper" over existing caching tools to match the interface are called adapters. For example, consider the adapters of the methods that we have already discussed:

All of them satisfy PSR-16 and therefore are applied the same way, but each has its own logic "under the hood".

For an example of usage, let's load APCu and array adapters using composer into our project.

composer require cache/array-adapter
composer require cache/apcu-adapter
# Or
composer req cache/apcu-adapter cache/array-adapter
Enter fullscreen mode Exit fullscreen mode

Let's imagine that we have a special class for getting products from a database. Let's call it ProductRepository, it has a find($id) method that returns a product by its ID, and if there is no such product, null.

class ProductRepository
{
    /**
     * In order not to complicate the example, we will stipulate that
     * an array is returned as a product, and if it is not, null
     */
    public function find(int $id): ?array
    {
        // ...
        // Getting data from the database
        return $someProduct;
    }
}
Enter fullscreen mode Exit fullscreen mode

If we want to enable caching, we should not do it inside the repository, because its responsibility is to return data from the database. Where then will we add the cache? There are several popular solutions, the simplest is an additional provider class. All he will do is try to get data from the cache, and if it didn't work out, he will contact the repository. To do this, we will define two dependencies in the constructor of such a class – our repository and CacheInterface. Why the interface? Because we will be able to use absolutely any of the mentioned adapters or other classes that satisfy PSR-16.

use Psr\SimpleCache\CacheInterface;

class ProductDataProvider
{
    private ProductRepository $productRepository;
    private CacheInterface $cache;

    public function __construct(ProductRepository $productRepository, CacheInterface $cache)
    {
        $this->productRepository = $productRepository;
        $this->cache             = $cache;
    }

    public function get(int $productId): ?array
    {
        $cacheKey = sprintf('product_%d', $productId);

        // Trying to get the product from the cache
        $product = $this->cache->get($cacheKey);
        if ($product !== null) {
            // If there is a product, we return it
            // Temporarily output echo to understand that the data is from the cache
            echo 'Data from cache' . PHP_EOL; // PHP_EOL is a line break
            return $product;
        }
        // If there is no product, we get it from the repository
        $product = $this->productRepository->find($productId);

        if ($product !== null) {
            // Now we will save the received product to the cache for future requests
            // Also temporarily output echo
            echo 'Data from DB' . PHP_EOL;
            $this->cache->set($cacheKey, $product);
        }

        return $product;
    }
}
Enter fullscreen mode Exit fullscreen mode

Our class is ready. Now let's look at its application in combination with an APCu adapter.

use Cache\Adapter\Apcu\ApcuCachePool;

// Connect the Composer autoloader
require_once 'vendor/autoload.php';

// Our repository
$productRepository = new ProductRepository();
// APCu-cache adapter. Does not require any additional settings
$cache = new ApcuCachePool();

// Creating a provider, passing dependencies
$productDataProvider = new ProductDataProvider(
    $productRepository,
    $cache
);

// If there is such a product in the database, it will come back to us
$product = $productDataProvider->get(1);
var_dump($product);
Enter fullscreen mode Exit fullscreen mode

If we want to replace APCu caching with an Array adapter or any other approach, we'll just pass it to the provider instead, because they all implement CacheInterface.

use Cache\Adapter\PHPArray\ArrayCachePool;
// ...
$productRepository = new ProductRepository();
//$cache = new ApcuCachePool();
$cache = new ArrayCachePool();
$productDataProvider = new ProductDataProvider(
    $productRepository,
    $cache
);
// ...
Enter fullscreen mode Exit fullscreen mode

Race condition and data updating

The cache works as long as we keep it up to date. This means that if a user wants to update a product, it must be updated both in the database and in our cache. However, there is one important nuance here.

Imagine that our project is used by a very large number of users. And two of them update the same entity at the same time. In this case, the following situation may occur:

  • User 1 received an entity from the cache
  • User 1 updated the entity in the DB
  • User 2 got the entity from the cache
  • User 1 has updated the data in the cache
  • User 2 updated the entity in the database but overwritten it with old data because the entity was irrelevant at the time of receipt
  • and so on...

This situation is called a race condition when several processes access the same resource at the same time and a version conflict may occur. To avoid such a problem, one simple rule should be followed:

When you receive any entity in the code to update it, always use data from the database.

In any situation when we need to get a product and we are not going to update it, we use a cache. If we want to update it - use data from the database.

You can either refer to ProductRepository in the right places, instead of ProductDataProvider or add an argument to the data provider's method. For example ($fromCache):

class ProductDataProvider
{
    // ...
    public function get(int $productId, bool $fromCache = true): ?array
    {
        $cacheKey = sprintf('product_%d', $productId);

        $product = $fromCache 
            ? $this->cache->get($cacheKey) 
            : null;
        if ($product !== null) {
            return $product;
        }
        $product = $this->productRepository->find($productId);

        if ($product !== null) {
            $this->cache->set($cacheKey, $product);
        }

        return $product;
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Caching requires additional efforts from the developer when making a project and its use may not always be appropriate. The decision to apply it or not should be based on the expected (or actual) load and your expectations of the response speed to the user.

However, regardless of whether you will use these approaches in your current projects or not, they should be studied and put into practice, because this skill will be useful to you when working in large teams.

Summing up the article, let's look at the key ideas:

  • Compliance with PSR-16 (or PSR-6) will allow you to easily connect a third-party library for caching and make your code understandable to other developers;
  • For small projects, APCu is a good caching solution, because it is easy to configure and uses RAM, access to which is very high;
  • For all PHP-compatible caching tools, some adapters can be viewed on the website php-cache.com;
  • Caching is a separate responsibility. Try to implement working with the cache in separate classes;
  • If we are going to update an entity, it should be obtained from the database. If we need an entity only for viewing– we can request it from the cache;
  • In large projects, Memcached or Redis are used to get the ability to scale.

Discussion (2)

Collapse
artemgoutsoul profile image
Artem Goutsoul

Nice article! Suggested topics for a follow up article:

  • multilevel caching, i.e. caching within array for the current request and in external cache for all requests
  • tagging cache entries to simplify cache invalidation
  • how to decide whether something needs caching
  • cache entry locking when repopulating expensive entries
Collapse
he110 profile image
Elijah Zobenko Author

Great suggestion! Thanks a lot