Using Symfony Service Decorators in Drupal 8
Using Symfony Service Decorators in Drupal 8
Mike Potter | Principal Engineer, CMS
October 25, 2016
Drupal 8 is more modular and customizable than ever before via plugins and the use of services. With the plugin system it is easy to subclass existing base plugins to add functionality, such as custom blocks, forms, filters, and much more. But how do you customize a service?
When writing the new Features module for Drupal 8, we needed a way to modify the existing ConfigInstaller service in Drupal core to allow pre-existing configuration to be installed on a site (allowing a feature to be installed on the same site that created it). All we needed was a small change to the findPreExistingConfiguration() method of the service.
Replacing a Service
Our first attempt involved completely replacing the normal config.installer service with our own subclass via altering the ServiceProvider:
class FeaturesServiceProvider extends ServiceProviderBase {
public function alter(ContainerBuilder $container) {
$definition = $container->getDefinition('config.installer');
$definition->setClass('Drupal\features\FeaturesConfigInstaller');
}
}
The problem with this method is if you want to install another module that also needs to alter the ConfigInstaller service. Who wins?
Introducing Decorators
The Symfony framework provides a mechanism known as service decorators that allow you to chain services together and override its behavior without completely replacing the service. All you need to do is add an argument to your new service that points to the parent service in your module.services.yml file.
mymodule.myservice:
class: Drupal\mymodule\MyService
public: false
decorates: parent.service
decoration_priority: 3
arguments: ['@mymodule.myservice.inner', ...]
Notice the “decorates” key which identifies which service is being overridden, and the @mymodule.myservice.inner argument being passed to the new service. The “decoration_priority” indicates the priority of the override with higher priorities running first.
Now we can support multiple modules that all override the same service. Imagine modules A and B that both override the config.installer service by providing their own decorators each with their own priority.
A new Constructor
In order to accept the new “inner” argument, you need to write a new _construct() method in your service class. You’ll want to save the previous service instance so you can call it elsewhere in your code.
public function __construct(WhateverServiceInterface $inner_service, $other_args...) {
$this->innerService = $inner_service; parent::__construct($other_args...);
}
When you implement the other methods in your service and want to call a public function from the base class, instead of using $this->method() you simply use $this->innerService->method().
Interfaces vs. Subclassing
The key part of using service decorators is that your new service needs to have the same class interface as the existing service. In many examples, this is shown literally as a new service that implements a specific interface. For example:
class MyService implements WhateverServiceInterface {}
However, the problem with this is that you’ll need to implement every method specified in the interface, duplicating much of the code from the existing service (unless your service really does need to do something completely different). You’ll see other examples showing a lot of this:
public function someMethod() {
$this->innerService->someMethod();
}
just to duplicate the existing functionality of the service.
In the case of Features, we didn’t want to re-implement the entire ConfigInstaller service, we just wanted to override a small piece of it. Fortunately, you don’t need to create an entirely new class, you can just subclass the existing service:
class MyService extends ParentServiceClass {}
Now you only need to implement the methods you actually want to change. You’ll still use $this->innerService to call any public functions within your methods, but you don’t need to re-implement every public method.
As an alternative to using $this->innerService everywhere, you can use the magic __call() method within your new class:
public function __call($method, $args) {
return call_user_func_array(array($this->innerService, $method), $args);
}
This will intercept all method calls not defined in your new service and redirect them to the innerService.
Public vs Protected Methods
The tricky part for Features was that the findPreExistingConfiguration() method we wanted to override is actually a protected method and calls other protected methods. Using a subclass of the existing service we can easily override a protected method, but what about calling $this->innerService? The innerService can only access the public functions in the interface and cannot be used to call other protected or private methods.
We decided to just give it a try to see what would happen. As expected, our overridden protected method completely replaced the behavior of the core service. Because it didn’t use the innerService argument, any additional module that also decorated the config.installer service also got the overridden protected method added by Features, as long as the decorator_priority of Features was higher than the other module.
This is exactly what we wanted! When overriding the protected method and not using innerService, you cannot have two decorators override the exact same method. But the two decorators still work fine together when they override different methods. While not as perfect as clean decorators it was still a much better solution than completely swapping the service using the ServiceProvider::alter() method. We added this to the 8.x-3.0-rc1 release of Features.
Conclusion
I created a d8_decorators github repository to demonstrate various different decorators and how they can be chained together and how they can override different methods or the same methods of core services. Feel free to play with enabling different modules to see the results.
What we learned is Symfony decorators are another powerful way to modify and extend Drupal 8. They can be used in more ways than perhaps intended via subclassing existing services and even to override protected service methods.