Symfony2: Yet more on that Twig Extension

In my last post I looked at the end at creating a Twig extension that would return the template to extend in order to pull the logic for the decision out of the template. The template names were just stored in class constants to keep the example simple; I mentioned that these could be moved out into configuration. A few people asked for more details on how that would be done so here it is. This is not specific to Twig extension but it does provide us with an example to look at exposing bundle configuration.

So to start with the extension class looks like this:

namespace Acme\DemoBundle\Twig;

class DemoExtension extends \Twig_Extension
{
    const FULL_TEMPLATE = '::full-layout.html.twig';
    const PARTIAL_TEMPLATE = '::partial-layout.html.twig';

    public function getFunctions()
    {
        return array(
            'parent_template' => new \Twig_Function_Method($this, 'getParentTemplate'),
        );
    }

    public function getParentTemplate()
    {
        if ($this->useFullTemplate()) {
            return self::FULL_TEMPLATE;
        }
        return self::PARTIAL_TEMPLATE; 
    }

    public function useFullTemplate()
    {
         //...
    }
}

with this service definition:

<!-- 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>
         <service id="acme.twig.demo_extension" 
                  class="Acme\DemoBundle\Twig\DemoExtension">
             <tag name="twig.extension" />
         </service>
     </services>
</container>

As we only have a couple of template names we could inject them individually to the constructor:

namespace Acme\DemoBundle\Twig;

class DemoExtension extends \Twig_Extension
{
    private $fullTemplate;
    private $partialTemplate;

    public function __construct($fullTemplate, $partialTemplate)
    {
        $this->fullTemplate = $fullTemplate;
        $this->partialTemplate = $partialTemplate;
    }

    public function getFunctions()
    {
        return array(
            'parent_template' => new \Twig_Function_Method($this, 'getParentTemplate'),
        );
    }

    public function getParentTemplate()
    {
        if ($this->useFullTemplate()) {
            return $this->fullTemplate;
        }
        return $this->partialTemplate; 
    }

    public function useFullTemplate()
    {
         //...
    }
}

and set them as parameters in our 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">
    <parameters>
        <parameter key="acme.templates.full">::full-layout.html.twig</parameter>
        <parameter key="acme.templates.partial">::partial-layout.html.twig</parameter>
    </parameters>
    <services>
         <service id="acme.twig.demo_extension" 
                  class="Acme\DemoBundle\Twig\DemoExtension">
             <argument>%acme.templates.full%</argument>
             <argument>%acme.templates.partial%</argument>
             <tag name="twig.extension" />
         </service>
     </services>
</container>

It may be better though to just accept an associative array of template names so that we can increase the number later without increasing the number of constructor arguments:

namespace Acme\DemoBundle\Twig;

class DemoExtension extends \Twig_Extension
{
    private $templates;

    public function __construct($templates)
    {
        $this->templates = $templates;
    }

    public function getFunctions()
    {
        return array(
            'parent_template' => new \Twig_Function_Method($this, 'getParentTemplate'),
        );
    }

    public function getParentTemplate()
    {
        if ($this->useFullTemplate()) {
            return $this->templates['full'];
        }
        return $this->templates['partial']; 
    }

    public function useFullTemplate()
    {
         //...
    }
}
<!-- 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">
    <parameters>
        <parameter key="acme.templates" type="collection">
             <parameter key="full">::full-layout.html.twig</parameter>
             <parameter key="partial">::partial-layout.html.twig</parameter>
        </parameter>
    </parameters>
    <services>
         <service id="acme.twig.demo_extension" 
                  class="Acme\DemoBundle\Twig\DemoExtension">
             <argument>%acme.templates.full%</argument>
             <argument>%acme.templates.partial%</argument>
             <tag name="twig.extension" />
         </service>
     </services>
</container>

So far this has just used setting parameters directly, however this does not give us much opportunity to validate the configuration. Currently the parameters are set in our bundle as well. If we decide that this bundle is worth sharing between applications then we would want to be able to set the parameters outside of the bundle in the app level config. We could just leave setting these parameters directly and move to doing that in the app parameters file:

# app/config/config.yml
parameters:
    acme.templates:
        full: "::full-layout.html.twig"
        partial: "::partial-layout.html.twig"

This still does not give us much control over what values get set. If we move to making them part of the config that the bundle exposes to the app level config then we can validate the values. It also makes it easier for someone else or ourselves in the future to see how to configure the bundle by looking at the allowed config.

We can instead make setting the template part of the bundle configuration instead of setting the parameters directly. This is the way the bundles that are part of the framework handle their configuration. The first step is to tell the bundle to expect configuration relating to the templates in the Configuration class that is in the DependencyInjection directory. This is autogenerated if you create your bundles with the generate:bundle console command:

<?php

namespace Acme\DemoBundle\DependencyInjection;

use Symfony\Component\Config\Definition\Builder\TreeBuilder;
use Symfony\Component\Config\Definition\ConfigurationInterface;

class Configuration implements ConfigurationInterface
{
    public function getConfigTreeBuilder()
    {
        $treeBuilder = new TreeBuilder();
        $rootNode = $treeBuilder->root('acme');

        $rootNode
            ->children()
                ->arrayNode('templates')
                ->useAttributeAsKey('name')
                ->isRequired()
                    ->requiresAtleastOneElement()
                    ->prototype('scalar')->end()
                ->end()
            ->end()
        ;

        return $treeBuilder;
    }
}

Here we are saying that we want an array of templates, at the moment all we have said is that it must exist and that it must have at least one element. We will now not be able to run the application as we get an exception due to not having met these requirements.

We can now add them to the config.yml file, instead of the parameters section we add them to a section with the top level key which matches out bundle’s name which was set as the root node name in the configuration class above. For the examples this is "acme":

# app/config/config.yml
acme:
    templates:
        full: "::full-layout.html.twig"
        partial: "::partial-layout.html.twig"

At this point we can remove the previous setting on these values in the parameters section of the config.

We should now be able to load the application without an exception about the config, however the template values are not yet being set as parameters. For that we also need to take the validated config and directly set the parameters. This is done in the Extension class in the DependencyInjection folder which is again generated automatically. In that we can see the configuration being processed in the first two lines of the load method. This is where the config files such as config.yml and config_dev.yml are merged and validated against the tree in the Configuration class.

We can use the processed configuration array to access the values and set them as parameters:

<?php

namespace Acme\DemoBundle\DependencyInjection;

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

class WHOTranslationExtension extends Extension
{
    public function load(array $configs, ContainerBuilder $container)
    {
        $configuration = new Configuration();
        $config = $this->processConfiguration($configuration, $configs);

        $container->setParameter('acme.templates', $config['templates']);

        $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
        $loader->load('services.xml');
    }
}

We are now back where we started and had to make a few changes to get there, so what have we actually gained? Not much so far and our Twig extension is still expecting particular keys to be set in that array, which is going to cause issues if they are missing. We can start to get the benefits of the changes by tightening up the validation settings in Configuration class.

We do not just want an array with some values in this case we want specific keys to be set. So to ensure that the "full" and "partial" keys have values we can change the configuration to:

//...
$rootNode
    ->children()
        ->arrayNode('templates')
            ->isRequired()
            ->children()
                ->scalarNode('main')->isRequired()->end()
                ->scalarNode('partial')->isRequired()->end()
            ->end()
        ->end()
    ->end()
;
//...

If we usual use the same name for these templates then we could set default values and use convention to avoid the explicit configuration but still leave open the possibility of overriding these values if we need to:

//...
$rootNode
    ->children()
        ->arrayNode('templates')
            ->addDefaultsIfNotSet()
            ->children()
                ->scalarNode('main')
                    ->defaultValue('::full-layout.html.twig')
                ->end()
                ->scalarNode('partial')
                    ->defaultValue('::partial-layout.html.twig')
                ->end()
            ->end()
        ->end()
    ->end()
;
//...

There is a console command to dump the config, showing the available key and any default values. This is dumped as Yaml so you can copy it as a starting pint for configuring the bundle. You can run it for this demo bundle with app/console config:dump-reference acme.

We can add additional information to this from the configuration tree using the info() and example() methods. Info is printed above the key it is set for as a comment; examples are printed as an inline comment after the value. The example is less useful where we already have default values so let’s go back to version where we didn’t and enhance it with some additional information:

//...
$rootNode
    ->children()
        ->arrayNode('templates')->info('The names of the parent templates to use')
            ->isRequired()
            ->children()
                ->scalarNode('main')
                    ->isRequired()
                    ->example('::full-layout.html.twig')
                ->end()
                ->scalarNode('partial')
                     ->isRequired()
                     ->example('::partial-layout.html.twig')
                ->end()
            ->end()
        ->end()
    ->end()
;
//...

Running the console command now gives us:

Default configuration for extension with alias: "acme"
acme:

    # The parent templates to use
    templates:
        main:                 ~ # Required, Example: ::full-layout.html.twig
        partial:              ~ # Required, Example: ::partial-layout.html.twig

So we have made our bundle more robust by validating its configuration and made more information about this configuration available to future uses of it. There are a lot more options available when validating configuration as there are a lot more possible requirements around what config is needed. There is more information on the possibilities when defining configuration in the official documentation.