PHP & Symfony About PHP and Symfony2 development

Symfony2: Some things I don't like about Bundles

Posted on by Matthias Noback

This article could have been titled "Ten things I hate about bundles", but that would be too harsh. The things I am going to describe, are not things I hate, but things I don't like. Besides, when I count them, I don't come to ten things...

Bundle extension discovery

A Symfony2 bundle can have an Extension class, which allows you to load service definitions from configuration files or define services for the application in a more dynamic way. From the Symfony documentation:

[The Extension] class should live in the DependencyInjection directory of your bundle and its name should be constructed by replacing the Bundle suffix of the Bundle class name with Extension. For example, the Extension class of AcmeHelloBundle would be called AcmeHelloExtension [...]

When you conform to the naming convention, your bundle's Extension class will automatically be recognized. But how? A quick look in the Kernel class reveals to us that it calls the method getContainerExtension() on each registered bundle (which is an object extending implementing BundleInterface):

namespace Symfony\Component\HttpKernel;
...
abstract class Kernel implements KernelInterface, TerminableInterface
{
    protected function prepareContainer(ContainerBuilder $container)
    {
        ...
        foreach ($this->bundles as $bundle) {
            if ($extension = $bundle->getContainerExtension()) {
                $container->registerExtension($extension);
                ...
            }
            ...
        }
        ...
    }
}

If the getContainerExtension() method of a bundle returns anything, it is assumed to be an instance of ExtensionInterface, in other words a service container extension.

My bundles almost always look like this:

namespace Matthias\Bundle\DemoBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;

class MatthiasDemoBundle extends Bundle
{
}

There is no implementation for getContainerExtension() there, so it must be in the parent class. And it is:

namespace Symfony\Component\HttpKernel\Bundle;
...
abstract class Bundle extends ContainerAware implements BundleInterface
{
    ...
    public function getContainerExtension()
    {
        if (null === $this->extension) {
            $basename = preg_replace('/Bundle$/', '', $this->getName());

            $class = $this->getNamespace().'\\DependencyInjection\\'.$basename.'Extension';
            if (class_exists($class)) {
                $extension = new $class();

                // check naming convention
                $expectedAlias = Container::underscore($basename);
                if ($expectedAlias != $extension->getAlias()) {
                    throw new \LogicException(sprintf(
                        'The extension alias for the default extension of a '.
                        'bundle must be the underscored version of the '.
                        'bundle name ("%s" instead of "%s")',
                        $expectedAlias, $extension->getAlias()
                    ));
                }

                $this->extension = $extension;
            } else {
                $this->extension = false;
            }
        }

        if ($this->extension) {
            return $this->extension;
        }
    }
}

My goodness, this is some ugly code, but more imporantly: this is magic! My extension class is nowhere explicitly used, nor registered as the one and only service container extension for my bundle. It is simply infered from the existence of a file with the expected name, containing the expected class (there is not even a check for the right interface), that I have a service container extension for my bundle! I don't like that at all.

Naming conventions

Even worse: the naming conventions prevent you from easily moving your Bundle and Extension class to another namespace (like you probably have noticed yourself).

I can move Matthias\Bundle\DemoBundle\MatthiasDemoBundle to Matthias\Bundle\TestBundle\MatthiasTestBundle with a simple search-and-replace, but I can not just move its DependencyInjection\MatthiasDemoExtension class to Matthias\Bundle\TestBundle\DependencyInjection, The class itself has to be renamed to MatthiasTestExtension, or it won't be recognized anymore.

Also somewhat annoying: the Bundle::getContainerExtension() puts a constraint on the alias of the service container extension: when my bundle is called MatthiasTestBundle, its alias should be matthias_test. But there is no real need for this, it is just a policy to prevent developers from overriding each other's (or worse: the framework's) bundle configuration.

This last rule is enforced in quite a strange way. Remember where the exception is being thrown? Yes, inside my own bundle class! I can easily override getContainerExtension() and skip the validation of the alias of my service container extension...

Registering service container extensions yourself

Because of the bundle magic described above, I like to implement the getContainerExtension() method myself and return an instance of the extension. The name of this class can be anything I like.

namespace Matthias\Bundle\TestBundle;

use Matthias\Bundle\TestBundle\DependencyInjection\CanBeAnything;

class MatthiasTestBundle extends Bundle
{
    public function getContainerExtension()
    {
        return new CanBeAnything();
    }
}

Now creation logic is also entirely on my side, where I like it to be.

Naming conventions, part two

As mentioned above, an extension has an alias, which you can retrieve by calling its method getAlias(). The standard implementation of this method is:

namespace Symfony\Component\DependencyInjection\Extension;
...
abstract class Extension implements ExtensionInterface, ConfigurationExtensionInterface
{
    ...
    public function getAlias()
    {
        $className = get_class($this);
        if (substr($className, -9) != 'Extension') {
            throw new BadMethodCallException('This extension does not follow the naming convention; you must overwrite the getAlias() method.');
        }
        $classBaseName = substr(strrchr($className, '\\'), 1, -9);

        return Container::underscore($classBaseName);
    }
    ...
}

This function also checks if we have followed the naming convention for extension classes. Since we have chosen to skip the naming convention check in the bundle class, we might just as well skip the check in the extension class too, and just implement the getAlias() method ourselves:

namespace Matthias\Bundle\TestBundle\DependencyInjection;

use Symfony\Component\HttpKernel\DependencyInjection\Extension;

class CanBeAnything extends Extension
{
    public function getAlias()
    {
        return 'matthias_test';
    }
}

Perfectly fine! Now moving a Bundle or Extension class won't break any existing configuration under the key matthias_test in config.yml and the likes.

Duplicate knowledge: the extension alias

As you may know, when you create a Configuration class for your bundle, you have to provide the configuration key, also known as the service container alias, as the name of the root node of the TreeBuilder instance:

namespace Matthias\Bundle\TestBundle\DependencyInjection;

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

class Configuration implements ConfigurationInterface
{
    public function getConfigTreeBuilder()
    {
        $treeBuilder = new TreeBuilder();

        // there it is: the service container alias!
        $rootNode = $treeBuilder->root('matthias_test');

        ...

        return $treeBuilder;
    }
}

It has always bothered me that this is somehow duplicate knowledge: it should not be necessary to use the exact same string here, which can also be retrieved by calling getAlias() on the Extension class. But the Configuration class can not do this: it has no access to the service container extension. Instead, it has to be the other way around: the Extension needs to provide its alias to the Configuration:

namespace Matthias\Bundle\TestBundle\DependencyInjection;

use Symfony\Component\DependencyInjection\ContainerBuilder;

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

        $processedConfig = $this
            ->processConfiguration($configuration, $config);
    }

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

Then the Configuration class needs to look something like this:

namespace Matthias\Bundle\TestBundle\DependencyInjection;

class Configuration implements ConfigurationInterface
{
    private $alias;

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

    public function getConfigTreeBuilder()
    {
        $treeBuilder = new TreeBuilder();

        // no more duplication!
        $rootNode = $treeBuilder->root($this->alias);

        ...

        return $treeBuilder;
    }
}

In conclusion

Well, it takes some extra steps, but only a few, and very easy ones. They will make it much easier to move bundles to another namespace without many other things breaking. They will also free you from naming conventions that would have otherwise been enforced.

Maybe most importantly: they finally remove some magic from bundles, that has been there as some kind of a reminiscence of the old days of symfony 1. It is not very modern to automatically load a file that is located in a certain directory and has a certain name.

(The same goes for console commands, for also registered automagically. See one of my previous post for more about this.)

One final remark: I still recommend you to conform to these conventions:

  • Your service container alias/configuration root should correspond to the name of your bundle, to prevent naming collissions.
  • Your Extension and Configuration classes should still be in the DependencyInjection directory/namespace.

These are good conventions, and they make it easy for any developer to understand and work with your bundle.

Categories: PHP Symfony2

Tags: bundle configuration service container

Comments: Comments