Service Decorators in Drupal 8

Selectively replace methods in Drupal 8 services without overriding the entire class
Tue, August 7th, 2018
blueoakinteractive

Preface

The goal of this article is to introduce you to the concept of decorators in Drupal 8. There are plenty of other resources on the subject matters discussed in the article, a few of which I've listed below the post. This is a new topic for me, so there may be errors in the descriptions below. Please provide comments with anything that's confusing or misrepresented.

Services and dependency injection in Drupal 8

In Drupal 8, services are managed by the Drupal Dependency Injection Container (DIC). Services are reusable components that Drupal core and contributed modules depend on for functionality. Behind services are PHP classes which are registered with the DIC. Having the services registered in the container allows the classes to be swappable and guarantees that any dependencies are type matched and instantiated. By using PHP interfaces, service classes can add or change the functionality of the base service without worrying about breaking other code that depends on the service.

Decorating an existing service

Object oriented PHP provides the ability to override or extend a parent class. However, sometimes you just want to swap out one method and not override the structure of the parent class. That is where decorators come in handy. With a decorator, you can implement the same interface as the desired class you wish to modify and only swap out the targeted functionality. Drupal then allows you to register decorators of a service and will invoke your decorator when calling the method of a service that you've overridden.

An example use case for a decorator

In Drupal 7, page manager, panels, and views could be used to build highly dynamic page layouts. The context provided by ctools, that all of those modules depends on, allowed you to pass properties from entities, references, and field values into views as arguments. With Drupal 8, the context system is built into core and is much more strict on what can be passed around. For example, if you define a contextual filter in views to reference a taxonomy term, you have to have a fully populated taxonomy term entity in "scope" to pass as the argument. This is because the context handler service in Drupal core makes sure the context you're passing to views matches the context type defined on the view. Views has the ability to load the required context from a string value using filters and validators, but that's not currently an option due to the strict context matching in core.

In our example, we need to swap out the ContextHandler::getMatchingContexts() method to allow strings to match any context.

In addition to the following decorator, the https://www.drupal.org/project/views_taxonomy_term_name_into_id module provides a views argument validator plugin that allows you to pass a taxonomy term name, as a string, from panels to views and have it converted to a taxonomy term id.

How to build a decorator

The first step to building your decorator is determining which service method(s) you want to override. We know that the context handler service is the one we want to alter, so we determine the machine name of that service in the DIC, which is context.handler.

We then create a MY_MODULE.services.yml file in a custom module to provide the decorator:

services:
  my_module.context_handler_decorator :
    class: Drupal\my_module\ContextHandlerDecorator
    public: false
    decorates: context.handler
    decoration_priority: 1
    arguments: ['@my_module.context_handler_decorator.inner']

The service must have an identifier, which we've called my_module.context_handler_decorator. Within that service decorator, we've defined the class to invoke, the class to decorate, and the arguments required to invoke our decorator. In this case, the argument is the parent context handler class.

The next step is to create our decorator class. We'll need to implement the same interface as the parent class so that other modules can invoke our decorator methods transparently. In this case, that's the ContextHandlerInterface interface. All interfaces have required methods with specific argument dependencies that you must define. The context handler interface requires filterPluginDefinitionsByContexts, checkRequirements, getMatchingContexts, and applyContextMapping be defined. In our example, we only want to alter the getMatchingContexts method, but still, have to define the others to implement the interface. To get around overriding the parent methods, we expect the parent service/class as an argument to instantiate our class and pass calls to those methods to the parent. See all calls to $this->subject in the example below.

<?php

namespace Drupal\my_module;

use Drupal\Core\Plugin\Context\ContextDefinitionInterface;
use Drupal\Core\Plugin\Context\ContextHandlerInterface;
use Drupal\Core\Plugin\Context\ContextInterface;
use Drupal\Core\Plugin\ContextAwarePluginInterface;

/**
 * Class ContextHandlerDecorator.
 *
 * Decorates the getMatchingContexts method to allow string contexts
 * to always match.
 *
 * @package Drupal\my_module
 */
class ContextHandlerDecorator implements ContextHandlerInterface {
  /**
   * @var \Drupal\Core\Plugin\Context\ContextHandlerInterface
   */
  protected $subject;

  /**
   * ContextHandlerDecorator constructor.
   *
   * @param \Drupal\Core\Plugin\Context\ContextHandlerInterface $subject
   *   The subject context handler.
   */
  public function __construct(ContextHandlerInterface $subject) {
    $this->subject = $subject;
  }

  /**
   * {@inheritdoc}
   */
  public function filterPluginDefinitionsByContexts(array $contexts, array $definitions) {
    return $this->subject->filterPluginDefinitionsByContexts($contexts, $definitions);
  }

  /**
   * {@inheritdoc}
   */
  public function checkRequirements(array $contexts, array $requirements) {
    return $this->subject->checkRequirements($contexts, $requirements);
  }

  /**
   * {@inheritdoc}
   */
  public function getMatchingContexts(array $contexts, ContextDefinitionInterface $definition) {
    return array_filter($contexts, function (ContextInterface $context) use ($definition) {
      $context_definition = $context->getContextDefinition();

      // Always allow string data types and invoke the
      // context definition's isSatisfiedBy() method
      // for others. 
      return $context_definition->getDataType() == 'string' || $definition->isSatisfiedBy($context);
    });
  }

  /**
   * {@inheritdoc}
   */
  public function applyContextMapping(ContextAwarePluginInterface $plugin, $contexts, $mappings = []) {
    return $this->subject->applyContextMapping($plugin, $contexts, $mappings);
  }

}

We've now replaced the logic in getMatchingContexts() with our own that allows string based contexts to always be a match without affecting the rest of the context handler class.

Why not just override the class?

Another option is to override and implement the entire class that is provided by the service. However, this means that your code is now responsible for providing the service to all other modules or services that depend on it.

Resources