Second level cache was introduced with Doctrine ORM 2.5.0 Release, it is still marked as experimental but it speeds up performance and people use it in production. I had lost time on finding how to implement this from different sources and make it work with doctrine extensions/knp doctrine behaviors.


TL;DR: Code for this can be found here.


Redis will cache your metadata and if you edit your Entity you will have to flush redis cache: app/console redis:flushdb

I will be running this on my Vagrant box using geerlingguy.redis role for Ansible.

Installation

First, we need doctrine/orm 2.5 or higher and sncRedis bundle with predis library.

composer require doctrine/doctrine-bundle doctrine/orm snc/redis-bundle predis/predis

Configuration

Redis configuration:

config.yml
#app/config/config.yml
snc_redis:
    clients:
        default:
            type: predis
            alias: default
            dsn: 'redis://localhost'
            logging: '%kernel.debug%'
        doctrine:
            type: predis
            alias: doctrine
            dsn: 'redis://localhost'
            logging: '%kernel.debug%'

    doctrine:
        metadata_cache:
            client: doctrine
            entity_manager: default
            namespace: 'dmc:'
        result_cache:
            client: doctrine
            entity_manager: default
            namespace: 'drc:'
        query_cache:
            client: doctrine
            entity_manager: default
            namespace: 'dqc:'
        second_level_cache:
            client: doctrine
            entity_manager: default
            namespace: 'dslc:'

Add Redis service:

services.yml
#app/config/services.yml
snc_second_level_cache:
    class: '%snc_redis.doctrine_cache_predis.class%'
    arguments: [ '@snc_redis.doctrine' ]

Enable Second Level Cache in Doctrine:

config.yml
#app/config/config.yml
doctrine:
    orm:
        entity_managers:
            default:
		/...
                # enable caching
                second_level_cache:
                    region_cache_driver:
                        type: service
                        id: snc_second_level_cache
                    enabled: true
                    region_lifetime: 86400

Cache Entities

In your entities you just have to add Cache annotation with usage under defining your ORM Entity and Table also you have to add that annotation to every association that you want to cache and that associated entity also has to have Cache annotation.

There are different types of usages and they are explained here.

Article.php
<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @package AppBundle\Entity
 * @ORM\Entity()
 * @ORM\Table()
 * @ORM\Cache(usage="NONSTRICT_READ_WRITE")
 */
class Article
{

    /**
     * @ORM\Column(type="string")
     */
    protected $name;

    /**
     * @ORM\ManyToOne(targetEntity="Category", inversedBy="article")
     * @ORM\JoinColumn(name="category_id", referencedColumnName="id")
     * @ORM\Cache(usage="NONSTRICT_READ_WRITE")
     */
    protected $category;
}
Category.php
<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @package AppBundle\Entity
 * @ORM\Entity()
 * @ORM\Table()
 * @ORM\Cache(usage="NONSTRICT_READ_WRITE")
 */
class Category
{
    /**
     * @ORM\Column(type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
    /**
     * @ORM\Column(type="string", nullable=false)
     */
    private $name;
}

Cache Entities with Translations

One more thing I needed was this to work with translations, so I tested it with 2 bundles that I use for translation, DoctrineExtensions and DoctrineBehaviors.

I won’t show you how to configure this bundles, only how to enable second level cache with translatable feature. I couldn’t get doctrine extensions to work with regions so examples are without regions.

Doctrine Extensions

This was simple because we already map $translations field in our entity, we only need to map association and translation entity like we did with Category in previous example.

ExtensionsArticle.php
<?php

namespace AppBundle\Entity;

/**
 * @package AppBundle\Entity
 * @ORM\Entity()
 * @ORM\Table()
 * @Gedmo\TranslationEntity(class="ExtensionsArticleTranslation")
 * @ORM\Cache(usage="NONSTRICT_READ_WRITE")
 */
class ExtensionsArticle implements TranslatableInterface
{
    use PersonalTranslatableTrait;
    /**
     * @var string
     * @ORM\Column(type="string")
     * @Gedmo\Translatable
     */
    protected $name;

     /**
     * @ORM\ManyToOne(targetEntity="Category", inversedBy="article")
     * @ORM\JoinColumn(name="category_id", referencedColumnName="id")
     * @ORM\Cache(usage="NONSTRICT_READ_WRITE")
     */
    protected $category;

      /**
     * @var ArrayCollection
     *
     * @ORM\OneToMany(
     *     targetEntity="ExtensionsArticleTranslation",
     *     mappedBy="object",
     *     cascade={"persist", "remove"}
     * )
     * @ORM\Cache(usage="NONSTRICT_READ_WRITE")
     */
    protected $translations;
}
ExtensionsArticleTranslation.php
<?php

namespace AppBundle\Entity;

/**
 * @ORM\Entity
 * @ORM\Table(name="article_translations",
 *     uniqueConstraints={@ORM\UniqueConstraint(name="lookup_unique_article_idx", columns={
 *         "locale", "object_id", "field"
 *     })}
 * )
 * @ORM\Cache(usage="NONSTRICT_READ_WRITE")
 */
class ExtensionsArticleTranslation extends AbstractPersonalTranslation
{
    /**
     * @ORM\ManyToOne(targetEntity="ExtensionsArticle", inversedBy="translations")
     * @ORM\JoinColumn(name="object_id", referencedColumnName="id", onDelete="CASCADE")
     */
    protected $object;
}

Doctrine Behaviors

This bundle was different, because we include trait in our class we do not have $translations field, also if you open trait you will see there is no metadata for that field, it is added later in EventListener but that doesn’t stop us from adding that field with metadata in our class manually.

BehaviorsArticle.php
<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Knp\DoctrineBehaviors\Model as ORMBehaviors;

/**
 * @package AppBundle\Entity
 * @ORM\Entity()
 * @ORM\Table()
 * @ORM\Cache(usage="NONSTRICT_READ_WRITE")
 */
class BehaviorsArticle implements TranslatableInterface
{
    use ORMBehaviors\Translatable\Translatable;
    /**
     * @ORM\ManyToOne(targetEntity="Category", inversedBy="article")
     * @ORM\JoinColumn(name="category_id", referencedColumnName="id")
     * @ORM\Cache(usage="NONSTRICT_READ_WRITE")
     */
    protected $category;

    /**
     * @ORM\OneToMany(
     *     targetEntity="BehaviorsArticleTranslation",
     *     orphanRemoval=true,
     *     mappedBy="translatable",
     *     indexBy="locale",
     *     cascade={"persist", "merge", "remove"}
     *)
     * @ORM\Cache(usage="NONSTRICT_READ_WRITE")
     */
    protected $translations;
}
BehaviorsArticleTranslation.php
<?

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Knp\DoctrineBehaviors\Model as ORMBehaviors;

/**
 *
 * @ORM\Table()
 * @ORM\Entity
 * @ORM\Cache(usage="NONSTRICT_READ_WRITE")
 */
class BehaviorsArticleTranslation
{
    use ORMBehaviors\Translatable\Translation;
    /**
     * @ORM\Column(type="string")
     */
    protected $name;
}

That is it, you have now enabled Second Level Cache and now you can leave the rest to symfony and doctrine, you can check your profiler for hits and if you edit your entities do not forget to flush redis with app/console redis:flushdb command, because your metadata for entities is now cached and app/console cache:clear isn’t enough anymore.

To work with regions the only difference you would have is instead of @ORM\Cache(usage="NONSTRICT_READ_WRITE") you would write @ORM\Cache(usage="NONSTRICT_READ_WRITE", region="your_region_name") and you would have to add custom region in your configuration, list of all doctrine configuration can be found here, at line 264 you see configuration for Second Level Cache and custom regions start at line 279.

When writing custom query for your repository you have to add →setCacheable(true):

public function getAllArticles()
{
    $qb = $this->getEntityManager()->createQueryBuilder()
            ->select('a')
            ->from('AppBundle:Article', 'a');

    return $qb->getQuery()
            ->setCacheable(true)
            //->setCacheRegion('your_region_name')
            ->getResult();
}

Also if working with regions you add →setCacheRegion('your_region_name').

Hope this helps someone and if you have any questions feel free to contact me.