Symfony2: Configuring different services for different environments

In my previous post I talked about avoiding optional dependencies. The example I used was of changing an optional dependency on a logger into a mandatory one. We injected a null logger implementation when we did not need logging. In this post I am going to look at this from the configuration point of view. In particular, how we could switch between which implementation gets injected for different environments.

We would not want to turn logging off for production but we may want to for an environment used for automated tests.

So we have the following services file:

<!-- src/Acme/DemoBundle/Resources/config/services.xml -->
<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        <!-- More services -->

        <service id="acme.demo.listener" class="Acme\DemoBundle\EventListener\ControllerListener">
            <argument type="service" id="twig.extension.acme.demo" />
            <argument type="service" id="logger" />
        </service>

        <service id="acme.demo.mailer" class="Acme\DemoBundle\Mailer">
            <argument type="service" id="logger" />
        </service>

    </services>
</container>

For our test environment we want to use the null logger implementation from PSR instead. So we need to inject a different logger service into all the services that use it. So what can we do? We could load a different xml file for the test environment by changing the extension class.

<?php

namespace Acme\DemoBundle\DependencyInjection;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Config\FileLocator;

class AcmeDemoExtension extends Extension
{
    public function load(array $configs, ContainerBuilder $container)
    {
        $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
        $loader->load('services.xml');

        if ($this->container->getParameter('kernel.environment') == 'test') {
            $loader->load('services_test.xml');
        }
    }

    public function getAlias()
    {
        return 'acme_demo';
    }
}

If we load a different one instead then we will have a lot of repetition. We would be better off loading an extra one and overriding services as necessary. We could define a new logger service and redefine all the services that use it:

<!-- src/Acme/DemoBundle/Resources/config/services_test.xml -->
<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        <!-- More services -->

        <service id="acme.demo.null_logger" class="Psr\Log\NullLogger" />

        <service id="acme.demo.listener" class="Acme\DemoBundle\EventListener\ControllerListener">
            <argument type="service" id="twig.extension.acme.demo" />
            <argument type="service" id="acme.demo.null_logger" />
        </service>

        <service id="acme.demo.mailer" class="Acme\DemoBundle\Mailer">
            <argument type="service" id="acme.demo.null_logger" />
        </service>

    </services>
</container>

This is still a lot of repeated configuration. We could redefine the logger service instead so that this is then used throughout.

<!-- src/Acme/DemoBundle/Resources/config/services_test.xml -->
<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        
        <service id="logger" class="Psr\Log\NullLogger" />

    </services>
</container>

This is an improvement as we now just have one different service in our extra service file. It would be good to reduce the changes further still. We can do this using a service alias. Then we can just change what the alias looks like. We can create an alias that points at the normal logger service. Then we can use this in our other services:

<!-- src/Acme/DemoBundle/Resources/config/services.xml -->
<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        <!-- More services -->

        <service id="acme.logger" alias="logger"/>

        <service id="acme.demo.null_logger" class="Psr\Log\NullLogger" />

        <service id="acme.demo.listener" class="Acme\DemoBundle\EventListener\ControllerListener">
           <argument type="service" id="twig.extension.acme.demo" />
            <argument type="service" id="acme.logger" />
        </service>

        <service id="acme.demo.mailer" class="Acme\DemoBundle\Mailer">
            <argument type="service" id="acme.logger" />
        </service>

    </services>
</container>

We can now set the alias to point at the normal logger service in our main service file. We can rename the null logger service id. Now that it does not clash with the main logger service we can move it to the main services xml file. In the extra services file we now just need to point the alias at the null service.

<!-- src/Acme/DemoBundle/Resources/config/services_test.xml -->
<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        
        <service id="acme.logger" alias="acme.demo.null_logger" />

    </services>
</container>

This does not make much difference as the null logger has no arguments. We could get rid of it altogether though. We can make the choice of service to alias to a parameter:

<!-- src/Acme/DemoBundle/Resources/config/services.xml -->
<?xml version="1.0" ?>

<container xmlns="http://symfony.com/schema/dic/services"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">

    <services>
        <!-- More services -->

        <service id="acme.logger" alias="%acme.logger.id%"/>

        <service id="acme.demo.null_logger" class="Psr\Log\NullLogger" />

        <service id="acme.demo.listener" class="Acme\DemoBundle\EventListener\ControllerListener">
            <argument type="service" id="twig.extension.acme.demo" />
            <argument type="service" id="acme.logger" />
        </service>

        <service id="acme.demo.mailer" class="Acme\DemoBundle\Mailer">
            <argument type="service" id="acme.logger" />
        </service>

    </services>
</container>

It can now be set in the application config and the extra services file removed altogether!

#app/config/parameters.yml in local dev installation
parameters:
    #...
    acme.logger.id: logger

and

#app/config/parameters.yml on test server
parameters:
    #...
    acme.logger.id: acme.demo.null_logger

As well as making the reconfiguration simpler we have decoupled the choice from the environment. I have previously written about this as a more general idea. We can now change the parameter on its own. This may not be that important for our logger but allows us to switch which services we use with ease.