Creating services with optional dependencies in Drupal 8

Submitted on Thu, 06/07/2018 - 11:08

Today I encountered a problem I did not think about earlier. After I pushed a fix to a project I am working on, the CI builds started showing errors. And the problem was coming from a message like this:

The service "mymodule.attachments_manager" has a dependency on a non-existent service "metatag.manager".

In many cases when you see that, it probably means your module was installed before a module it depends on. For example, in this case, it would seem that this module depends on metatag, and so declaring it as a dependency would fix the issue. And for sure, it would. But sometimes dependencies are not black and white.

This particular service does some handling of the attachments when used together with metatag. It does so, because it is a module we use across projects, and we can not be sure metatag is used in any given project. So it's only used in a way that is something like this:

/**
 * Implements hook_page_attachments().
 */
function mymodule_page_attachments(array &$attachments) {
  if (!\Drupal::moduleHandler()->moduleExists('metatag')) {
    return;
  }
  \Drupal::service('mymodule.attachments_manager')->handlePageAttachments($attachments);
}

Now, what this means, is that for the attachments manager to be useful, we need metatag. If we do not have metatag, we do not even need this service. So basically, the service depends on metatag (as it uses the service metatag.manager), but the module does not (as it does not even need its own service if metatag is not installed).

Now, there are several ways you could go about fixing this for a given project. Creating a new module that depends on metatag could be one way. But today, let's look at how we can make this service have an optional dependency on another service.

At first the service definition looked like this:

mymodule.attachments_manager:
  class: Drupal\mymodule\MyModuleAttachmentsManager
  arguments: ['@current_route_match', '@module_handler', '@metatag.manager', '@entity.repository']

This would contruct a class instance of MyModuleAttachmentsManager with the following function signature:

public function __construct(RouteMatchInterface $route_match, 
  ModuleHandlerInterface $module_handler, 
  MetatagManager $metatag_manager, 
  EntityRepositoryInterface $entity_repo) {
}

Now, this could never work if this module was installed before metatag (which it very well could, since it does not depend on it). A solution then would be to make the metatag.manager service optional. Which is something we can do by removing it from the constructor and create a setter for it.

public function __construct(RouteMatchInterface $route_match, 
  ModuleHandlerInterface $module_handler, 
  EntityRepositoryInterface $entity_repo) {
  // Constructor code.
}

/**
 * Set meta tag manager.
 *
 * @param \Drupal\metatag\MetatagManager $metatagManager
 *   Meta tag manager.
 */
public function setMetatagManager(MetatagManager $metatagManager) {
  $this->metatagManager = $metatagManager;
}

OK, so far so good. It can now be constructed without having a MetaTagManager. But how do we communicate this in the service definition? Turns out the documentation for Symfony service container has the answer.

When you define a service, you can specify calls to be made when creating the service, which would be ignored if the service does not exist. Like so:

mymodule.attachments_manager:
  class: Drupal\mymodule\MyModuleAttachmentsManager
  arguments: ['@current_route_match', '@module_handler', '@entity.repository']
  calls:
    - [setMetatagManager, ['@?metatag.manager']]

So there we have it! The service can be instantiated without relying on the metatag.manager service. And if it is available in the service container, the method setMetatagManager will be called with the service, and our service will have it available in the cases where we need it.

Now let's finish off with an animated gif related to "service container".

Service container