Project Management

Caching complex objects with Laravel 4.2 and Redis

July 20, 2016
by
Jakub Wrona

Laravel supports various cache drivers out of the box. One of them is Redis. I am not going to describe Redis with it's features and advantages here, it's not the purpose of this document. The only important point in scope of this document is that Redis supports tags. Feel free to google a bit what is „cache tagging”.

I also won't explain how to install Redis. If you read this article most probably you have the stack ready. If you just want to „play with it” consider using docker:

docker run -d -p 6379:6379 redis:alpine redis-server

Let's consider the following diagram:

eerd-diagram

In short, an employee may work at a company or at an enterprise. Company may belong to an enterprise, both company and enterprise may have few address records attached. The many-to-many relation is used so that you don't need to alter entity table too often, instead you add another pivots when your project is growing.

Full information about a given company is the company information itself (like name, vat number etc.), all addresses attached, all employees working at that company and if the company is associated within some enterprise.

A company with 4 users, 2 addresses and an enterprise could be tagged with the following tags (the structure is entity name + id).

Array
(
[0] => COMPANY_1
[1] => ADDRESS_11
[2] => ADDRESS_12
[3] => ENTERPRISE_3
[4] => EMPLOYEE_31
[5] => EMPLOYEE_34
[6] => EMPLOYEE_43
[7] => EMPLOYEE_44
)

To store that full company information with tags in cache we can use the following:

Cache::tags($cacheTags)->forever('COMPANY_1', $fullCompanyData);

In case of a state change of any of the related entities we need to flush also the $fullCompanyData one (that is COMPANY_1).

The easiest, but also IMO the best way to achieve it would be to have observers registered and hooking into right eloquent models.

<?php
namespace Api\v1\Observers;

use Cache;

class GenericObserver
{
    protected function clearCache(array $items)
    {
        foreach ($items as $item) {
            Cache::tags($item)->flush();
            Cache::forget($item);
        }
    }

    public function updated($model)
    {
        $cache = [$model->getCacheTag()];
        $this->clearCache($cache);
    }
}

How to register an observer? There are quite few ways of achieving this in L4.2, my favourite is with the boot() method:

class Company extends \Eloquent
{
    use CacheTagPrefix;
    protected $table        = 'company';

    public static function boot()
    {
        parent::boot();
        self::observe(new GenericObserver);
    }
}

The following events are fired when doing CRUD operations and we can easily hook them: creating, created, updating, updated, saving, saved, deleting, deleted, restoring, restored

The entity is passed to the hook method, but you can see a getCacheTag() method called on the entity. It comes from a trait that I've added to all entities which defines our naming / tagging conventions

<?php
namespace Api\v1;

trait CacheTagPrefix
{
    public function getCacheTagPrefix()
    {
        return strtoupper(substr(strrchr(get_class($this), '\\'), 1)).'_';
    }

    public function getCacheTag()
    {
        return $this->getCacheTagPrefix().$this->attributes[$this->primaryKey];
    }
}

So we have pretty much all we need. When any object is updated it invalidates all cache that is tagged with it. For example updating an employee with id 31 will flush cache tagged with EMPLOYEE_31, that means also the $fullCompanyData tagged with EMPLOYEE_31 will be flushed.

But when we create a new Record (no matter what entity is it) we also need to invalidate cache, don't we? So let's implement the created() hook in our GenericObserver class.

<?php
namespace Api\v1\Observers;

use Cache;

class GenericObserver
{
    protected function clearCache(array $items)
    {
        foreach ($items as $item) {
            Cache::tags($item)->flush();
            Cache::forget($item);
        }
    }

    public function updated($model)
    {
        $cache = [$model->getCacheTag()];
        $this->clearCache($cache);
    }

    public function created($model)
    {
        $this->updated($model);
    }
}

It will work OK, it will flush all cache tagged by the tag. But what if it was a new Employee and what if it was attached to an existing (and very likely cached) Company?
We have to invalidate the company's cached data, as instead of n employees working at it we have n+1 now.

Unfortunately due to the way database constraints work (we have to insert the employee first, and then insert the relation between company and employee into the many-to-many PIVOT table) and more over because of PIVOTS not firing events on attach() and detach() methods it's not so easy. When trying to hook into the employee's observer created() method one could see that calling $model→companies() return empty collection. That's exactly because this event is fired right after inserting new employee, and obviously before inserting the relation. The workaround is crucial. Remove the PIVOT and implement a full model class for the PIVOT table:

<?php
namespace Api\v1\Models;

use Api\v1\Observers\EmployeeRelationObserver;
/**
* @property mixed employee_id
* @property mixed company_id
*/
class EmployeeHasCompany extends \Eloquent
{
    protected $table        = 'employee_has_company';

    public static function boot()
    {
        parent::boot();
        self::observe(new EmployeeRelationObserver);
    }
}

Also the observer needs to behave a bit different. First of all I want just one observer handling both PIVOTS for employees→companies and employees→enterprises. Then - it doesn't need to invalidate "itself" but the corresponding object instead.

<?php
namespace Api\v1\Observers;

use Api\v1\Accounts\EmployeeHasCompany;
use Api\v1\Accounts\EmployeeHasEnterprise;

class EmployeeRelationObserver extends GenericObserver
{
    const COMPANY_TAG_PREFIX = 'COMPANY_';
    const ENTERPRISE_TAG_PREFIX = 'ENTERPRISE_';

    public function created($model)
    {
        if ($model instanceof EmployeeHasCompany) {
            $cache = [self::COMPANY_TAG_PREFIX . $model->company_id];
        } elseif ($model instanceof EmployeeHasEnterprise) {
            $cache = [self::ENTERPRISE_TAG_PREFIX . $model->enterprise_id];
        }
        $this->clearCache($cache);
    }

    public function deleted($model)
    {
        $this->created($model);
    }
}

I've implemented also the deleted() method, because the same rule is valid when deleting employees.

Still reading? That means all should be clear but where did I get the list of tags when building the $fullCompanyData? I had to implement a „helper” which iterates through all related entities and generates it. I've put into my RepoAbstract class extended by all repositories, but you can put it anyware. You may also consider to refactor it a bit to be a function.

public function generateCacheTags($object, array $cacheTags = [])
{
    foreach ($object->getRelations() as $relation => $items) {
        if ($items instanceof \Illuminate\Database\Eloquent\Collection) {
            foreach ($items as $item) {
                if (!empty($item->getRelations())) {
                    $cacheTags = $this->generateCacheTags($item, $cacheTags);
                }
                $reflection = new \ReflectionClass($item);
                if ($reflection->hasMethod('getCacheTag')) {
                    $cacheTags[] = $item->getCacheTag();
                }
            }
        } else {
            if (!empty($items->getRelations())) {
                $cacheTags = $this->generateCacheTags($item, $cacheTags);
            }
            $reflection = new \ReflectionClass($items);
            if ($reflection->hasMethod('getCacheTag')) {
                $cacheTags[] = $items->getCacheTag();
            }
        }
    }

    $reflection = new \ReflectionClass($object);
    if ($reflection->hasMethod('getCacheTag')) {
        $cacheTags[] = $object->getCacheTag();
    }
    return array_values(array_unique($cacheTags));
}

Do you need regulatory compliance software solutions?

Accelerate your digital evolution in compliance with financial market regulations. Minimize risk, increase security, and meet supervisory requirements.

Do you need bespoke software development?

Create innovative software in accordance with the highest security standards and financial market regulations.

Do you need cloud-powered innovations?

Harness the full potential of the cloud, from migration and optimization to scaling and the development of native applications and SaaS platforms.

Do you need data-driven solutions?

Make smarter decisions based on data, solve key challenges, and increase the competitiveness of your business.

Do you need to create high-performance web app?

Accelerate development, reduce costs and reach your goals faster.