Symfony2 Tutorial - Writing a compiler pass

Jan 23rd 2015

This is the second article in a three part series on Symfony2 Compiler Passes. This article is a tutorial on writing a compiler pass.

The first article, explaining what a compiler pass is, can be found here.

Introduction

For a project I have been working on recently I required some of my bundles to add some doctrine configuration without modifying the global config files. Doctrine allows mapping using interfaces which are then resolved to concrete classes at runtime. This is really useful if a bundle containing an entity does not also contain the concrete class that satisfies it's relational mapping.

The problem

The problem is, the doctrine bundle resolves these mappings in it's extension. So any bundle that is included in the App Kernel after the doctrine bundle (likely most of your custom and 3rd party bundles) will not be able to add mappings by simply updating the resolve_target_entities parameter because it has already been used. So the question is: How do we get mappings into doctrine directly from our bundle?

My Solution

How does each bundle provide it's own interface mappings

Each bundle could of course grab the doctrine.orm.listeners.resolve_target_entity definition (in a compiler pass) and add method calls for each of it's own interface mappings, however that is a lot of repeated code if we have multiple bundles that require mappings.

Instead I was inspired by the Oro platform code (when it was in alpha) to allow developers to write their interface mappings in a specifically named configuration file and read these files in my DI extension. This removes the repetition issue and trades it for a reserved configuration filename:

//MyBundle/DependencyInjection/MyBundleExtension.php
    public function load(array $configs, ContainerBuilder $container)
    {
        //..
        $entityResolutions = array();
        foreach ($container->getParameter('kernel.bundles') as $bundle) {
            $reflection = new ReflectionClass($bundle);
            if (is_file($file = dirname($reflection->getFilename()) . '/Resources/config/entity_resolution.yml')) {
                $resolutionConfig = Yaml::parse(realpath($file));
                $entityResolutions = array_merge_recursive($entityResolutions, $resolutionConfig);
            }
        }
        $container->setParameter('lrotherfield.orm.entity_resolutions', $entityResolutions);

What is going on:

  1. We loop through every bundle that is registered in the kernel.
  2. For each bundle we check to see if it has a file called entity_resolution.yml in Resources/config.
  3. If the file exists, we parse out the content and add it to an array of resolutions
  4. Once we have iterated over all the bundles in the kernel we set the mapping array as a parameter on the container

Improvement: We could require the config file to be in a sub directory using our namespace e.g "Resources/config/lrotherfield/entity_resolutions.yml" if we are worried about conflicts and reserving filenames.

What do the mappings look like

What I needed to provide was a simple set of mappings that could be overwritten easily. My approach was to create arrays of mappings in Yaml with the following keys:

  • Interface: The interface that is used in the actual doctrine mapping code
  • Class: The concrete class instance that should be substituted for the interface
  • Priority: The priority that this mapping has, should two exist this is the way I wanted to resolve which one gets used

Example Yaml file:

#SecondBundle entity_resolution.yml
-
    interface: LRotherfieldBundleFirstBundleEntityEntityInterface
    class:     LRotherfieldBundleSecondBundleEntityEntityConcrete

-
    interface: LRotherfieldBundleThirdBundleEntityEntityInterface
    class:     LRotherfieldBundleSecondBundleEntityEntityConcrete
    priority:  50
#FourthBundle entity_resolution.yml
-
    interface: LRotherfieldBundleThirdBundleEntityEntityInterface
    class:     LRotherfieldBundleFourthBundleEntityEntityConcrete
    priority:  60

As you can see, the second and fourth bundle are competing for the entity interface in the third bundle. However the fourth bundle has a greater priority so we want that one to get loaded.

How do the mappings get registerd with Doctrine

Finally we have to take the contents of our mappings parameter lrotherfield.orm.entity_resolutions and pass it to Doctrine. This is where the compiler pass comes in. For each registered compiler pass, the process() method will be called and passed the container builder. So we create a process method that does our business logic:

use SymfonyComponentDependencyInjectionCompilerCompilerPassInterface;
use SymfonyComponentDependencyInjectionContainerBuilder;
use SymfonyComponentDependencyInjectionReference;

class EntityResolverPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {

Our class must implement the CompilerPassInterface so that it can be added to the compiler.

Next we check to see the container has the doctrine.orm.listeners.resolve_target_entity service defined. If not, we will throw an exception as our code cannot function without real mappings. If the container has the definition then we retrieve it so that we can add our mappings to it:

if (!$container->hasDefinition('doctrine.orm.listeners.resolve_target_entity')) {
    throw new RuntimeException('doctrine.orm.listeners.resolve_target_entity must be defined');
}
$def = $container->findDefinition('doctrine.orm.listeners.resolve_target_entity');

I have chosen a RuntimeException because the definitions for services are defined at runtime and can change. (Choosing exceptions is always a tricky one for me)

We now need to load all of the new resolutions from the container as they are stored in the new lrotherfield.orm.entity_resolutions parameter.

$resolutions = $container->getParameter('lrotherfield.orm.entity_resolutions');

We will also run a sort on the resolutions before using them to order them by priority (with uasort and associated callback) . For readability I have abstracted this sorting:

private function sortResolutions($a, $b)
{
    $aPriority = isset($a['priority']) ? (int) $a['priority'] : 0;
    $bPriority = isset($b['priority']) ? (int) $b['priority'] : 0;

    return $aPriority > $bPriority ? 1 : ($aPriority < $bPriority ? -1 : 0);
}

The last thing to do is loop over the resolutions and then individually pass them to doctrine by adding a method call to the addResolveTargetEntity method on the doctrine.orm.listeners.resolve_target_entityservice definition with the interface and concrete class. When the resolve target entity service is instantiated, addResolveTargetEntity will be called one time for each mapping.

if (!empty($resolutions)) {
    uasort($resolutions, [$this, 'sortResolutions']);
    foreach ($resolutions as $resolution) {
        $def->addMethodCall(
           'addResolveTargetEntity',
            array($resolution['interface'], $resolution['class'], [])
        );
    }
}
Putting it all together

Putting the above code together we get a compiler pass that looks like:

...
use SymfonyComponentDependencyInjectionCompilerCompilerPassInterface;
use SymfonyComponentDependencyInjectionContainerBuilder;
use SymfonyComponentDependencyInjectionReference;

/**
 * Class EntityResolverPass
 *
 * @author Luke Rotherfield
 */
class EntityResolverPass implements CompilerPassInterface
{

    /**
     * Add any bundle level entity-interface resolutions to the doctrine entity resolver
     *
     * @param ContainerBuilder $container
     *
     * @throws InvalidArgumentException
     */
    public function process(ContainerBuilder $container)
    {
        if (!$container->hasDefinition('doctrine.orm.listeners.resolve_target_entity')) {
            throw new RuntimeException('doctrine.orm.listeners.resolve_target_entity must be defined');
        }
        $def = $container->findDefinition('doctrine.orm.listeners.resolve_target_entity');
        $resolutions = $container->getParameter('lrotherfield.orm.entity_resolutions');
        if (!empty($resolutions)) {
            uasort($resolutions, [$this, 'sortResolutions']);
            foreach ($resolutions as $resolution) {
                $def->addMethodCall( 'addResolveTargetEntity', array($resolution['interface'], $resolution['class'], []) );
            }
        }
    }

    private function sortResolutions($a, $b)
    {
        $aPriority = isset($a['priority']) ? (int) $a['priority'] : 0;
        $bPriority = isset($b['priority']) ? (int) $b['priority'] : 0;

        return $aPriority > $bPriority ? 1 : ($aPriority < $bPriority ? -1 : 0);
    }
}

Final step, register the compiler pass

In the bundle class (the class that we register with the AppKernel) we now need to add the compiler pass in the build method so that it gets run:

    public function build(ContainerBuilder $container)
    {
        //…
        $container->addCompilerPass(new EntityResolverPass());
    }

The addCompilerPass method allows us to pass a new instance of a CompilerPassInterface to the container. It also allows us to specify the compiler pass type should we want to have our pass run at a time other than before optimisation:

A real world example using interfaces for mapping

I actually discovered mapping using interfaces when studying Sylius to see how they had made their system decoupled. Although I have yet to use their code for a project, it has been great to be able to dig through thier code as it seems to me they have really invested in well designed, best practice code.

The Sylius ecommerce platform makes use of this functionality throughout it's bundles so that they can be used individually (decoupled) in other systems with other concrete classes without the developer having to update the mappings.

Having recently dug a little bit further into Sylius, I can see that they have a similar solution for adding these mappings to Doctrine. The biggest difference is how they allow the developers to pass the mappings. This is an interesting approach as it does not require a specifically named file in the config dir and it does not have to loop through every bundle to set up the mappings. So definitely a more effeciant approach.

Hopefully that all makes sense, my plan for the final article in this series is a tutorial on unit testing our compiler pass as unit testing is something I have struggled for a long time to get my head around and is certainly worth doing. I will add a link here when it is done.

Luke Rotherfield

Symfony2 Developer in CT USA. Luke is a Symfony2 wizard and has written some sweet libraries of his own. Luke loves Jesus, his gorgeous wife and his two beautiful daughters :)