Backwards compatible bundle releases

Posted on by Matthias Noback

The problem

With a new bundle release you may want to rename services or parameters, make a service private, change some constructor arguments, change the structure of the bundle configuration, etc. Some of these changes may acually be backwards incompatible changes for the users of that bundle. Luckily, the Symfony DependenyInjection component and Config component both provide you with some options to prevent such backwards compatibility (BC) breaks. If you want to know more about backwards compatibility and bundle versioning, please read my previous article on this subject.

This article gives you an overview of the things you can do to prevent BC breaks between releases of your bundles.

Renaming things

The bundle itself

You can't, without introducing a BC break.

// don't change the name
class MyOldBundle extends Bundle
{
}

The container extension alias

You can't, without introducing a BC break.

use Symfony\Component\HttpKernel\DependencyInjection\Extension;

class MyBundleExtension extends Extension
{
    ...

    public function getAlias()
    {
        // don't do this
        return 'new_name';
    }
}

Parameters

If you want to change the name of a parameter, add another parameter with the desired name, which receives the value of the existing parameter by substitution:

parameters:
    old_parameter: same_value
    new_parameter: %old_parameter%
class MyBundleExtension extends Extension
{
    public function load(array $config, ContainerBuilder $container)
    {
        $container->setParameter('old_parameter', 'same_value');
        $container->setParameter('new_parameter', '%old_parameter%');
    }
}

Now existing bundles or the user may still change the value of old_parameter and that change will propagate to the new parameter too (thanks WouterJNL for suggesting this approach!).

Config keys

The container extension alias can not be changed without causing a BC break, but it is possible to rename config keys. Just make sure you fix the structure of any existing user configuration values before processing the configuration:

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

        $rootNode
            ->beforeNormalization()
                ->always(function(array $v) {
                    if (isset($v['old_name'])) {
                        // move existing values to the right key
                        $v['new_name'] = $v['old_name'];

                        // remove invalid key
                        unset($v['old_name']);
                    }

                    return $v;
                })
            ->end()
            ->children()
                ->scalarNode('new_name')->end()
            ->end()
        ;
    }
}

Service definitions

If you want to rename a service definition, add an alias with the name of the old service:

services:
    new_service_name:
        ...

    old_service_name:
        alias: new_service_name

This may cause a problem in user code, because during the container build phase they may call $container->getDefinition('old_service_name') which will cause an error if old_service_name is not an actual service definition, but an alias. This is why user code should always use $container->findDefintion($id), which resolves aliases to their actual service definitions.

Method names for setter injection

services:
    subject_service:
        calls:
            # we want to change the name of the method: addListener
            - [addListener, [@listener1]]
            - [addListener, [@listener2]]

This one is actually in the grey area between the bundle and the library. You can only change the method names used for setter injection if users aren't supposed to call those methods themselves. This should actually never be the case. You should just offer an extension point that takes away the need for users to call those methods. The best option is to create a compiler pass for that:

namespace MyBundle\DependencyInjection\Compiler;

use Symfony\Component\DependencyInjection\ContainerBuilder;

class InjectListenersPass implements CompilerPassInterface
{
    public function process(ContainerBuilder $container)
    {
        $subjectDefinition = $container->findDefintion('subject');

        foreach ($container->findTaggedServiceIds('listener') as $serviceId => $tags) {
            $subjectDefinition->addMethodCall(
                'addListener',
                array(new Reference($serviceId))
            );
        }
    }
}

You need to add this compiler pass to the list of container compiler passes in the bundle class:

class MyBundle extends Bundle
{
    public function build(ContainerBuilder $container)
    {
        $container->addCompilerPass(new InjectListenersPass());
    }
}

From now on, users can register their own listener services like this:

services:
    user_listener_service:
        tags:
            - { name: listener }

The compiler pass will then call the addListener() method for each of the registered listeners.

From now on you can change the method name used for this type of setter injection at any time, because the user doesn't call it explicitly anymore.

Changing visibility

Parameters

This doesn't apply to parameters, they are all public.

Service definitions

Service definitions can be public or private:

services:
    a_public_service:
        # a service is public by default
        public: true

    a_private_service:
        public: false

When a definition is marked "private" you can't fetch it directly from the container by calling $container->get($id). Private services can only be used as arguments of other services.

If a service definition was previously public, you can't change it to be private, because some users may rely on it to be available by calling $container->get($id). The only option here is to rename the service, make it private, and then add an alias for it with the old name. This will effectively make the service public again (as Christophe Coevoet mentioned in a comment: it is also possible to create private aliases).

services:
    a_public_service:
        # an alias is always public
        alias: a_private_service

    a_private_service:
        public: false

If a service definition was previously private, you can change it to public at any time. There are no ways in which a user could have used a private service that won't work when the service becomes public.

Changing values

Service definitions

I think we should agree on arguments of service definitions to be private property of the bundle. Users should not rely on them to stay the same between two releases.

Config values

If you want to change allowed values for bundle configuration, you should fix existing config values, by using the beforeNormalization() method (we discussed this previously).

If you want to remove existing config keys, you will have to unset them using beforeNormalization() too, or they will trigger errors about unknown keys.

If you want to add new required configuration values, you should provide sensible defaults for them, to accomodate existing users:

$rootNode
    ->children()
        ->scalarNode('required_key')
            ->isRequired()
            ->defaultValue('sensible_default')
        end()
    ->end()
;

If the parent key is a new key and is allowed to be missing, you should add ->addDefaultsIfNotSet() to the parent array node:

$rootNode
    ->children()
        ->arrayNode('optional_key')
            ->addDefaultsIfNotSet()
            ->children()
                ->scalarNode('required_key')
                    ->isRequired()
                    ->defaultValue('sensible_default')
                end()
            ->end()
        ->end()
    ->end()
;

New optional config keys should be no problem.

Conclusion

Of course, if the change you want to introduce is not compatible with one of the proposed BC-friendly options, you should release a new major version of your bundle. No problem with that, it just needs to be a conscious decision and you might want to try a bit harder before you decide to do that.

I think this article contains a fairly exhaustive list of ways to prevent BC breaks between bundle releases. If you have a suggestion for this list, just let me know.

PHP Symfony2 bundle dependency injection service container package design
Comments
This website uses MailComments: you can send your comments to this post by email. Read more about MailComments, including suggestions for writing your comments (in HTML or Markdown).
Christophe Coevoet

Just a small issue in your article: aliases are not always public. It is possible to create private aliases as well

Matthias Noback

Ah, I didn't know that. Thanks! I got it mixed up with "using an alias you can effectively make a private service public again". It makes sense though and seems very useful to me.

Christophe Coevoet

You can make public aliases of private services (the service id will not be usable at runtime, but the alias id will), public aliases of public services (both ids will be available at runtime) or private aliases of any service (the alias id will never be available at runtime as it will always be deleted from the container after resolving the alias references to the real service)

Matthias Noback

By the way, I added this to the article.

Berny Cantos

Thanks! Great collection of tips to avoid BC breaks! This should be in every developer's tool belt, IMO.

Just a typo, it should be 'When a definition is marked "private"' instead of "public" in 'Changing visibility/Service definitions'.

Matthias Noback

Thanks!
I fixed the typo.

Wouter J

In "Renaming things: Parameters", I would use:

parameters: { new_parameter: "%old_parameter%" }

instead. This allows other bundles which are registered after the bundle with those parameters to change the parameter value of old_parameter (and it means new_parameter will have that change too)

Matthias Noback

Excellent suggestion! I will change that.