Extend service's functionality by decorating it

By now, every Drupal developer should know that it's a bad idea to overwrite core and contributed code. Especially when using Composer correctly and therefore only store the composer.lock file to the version control system. It should be imminent that changing files containing core code is a no-brainer (don't hack core!). But what should/can you do when you want to extend or overwrite existing code and still use the rest of the functionality?

An option is to write patches and when using Composer in combination with cweagans/composer-patches it becomes pretty simple to apply it automatically when building an environment (development, staging, production). However, this shouldn't be an acceptable solution either. Patches should be used to fix code that is incorrect in a generic manner, not to improve functionality for your project's specific use case. As a general rule of thumb you can only consider a patch a good solution when you have attached it to an issue on drupal.org.

Another solution is to replace a service completely with a different object. Similar to RouteSubscriber: serve a different page on an existing URL, where the original controller has been removed and used a form instead. In that specific example it makes perfect sense to use a completely different class, however, what would be the neatest solution when only a small part of the code (of a service) needs to be altered? Extending the original class, implementing the custom code change in it and use this one instead, not only feels counter intuitive it also limits flexibility in extending functionality (though the latter is mostly theoretical).

The correct solution for this is to provide a service decorator. In your module's .sevices.yml file you have to register the class and tell which service it decorates. In the example below the VendorStreamWrapperAssetOptimizer decorates the asset.css.optimizer service that is provided by Drupal core, gets an appropriate decoration_priority and is marked as a private service (i.e., public: false, no need for this service to be instantiated itself). Important here is that the original service is injected as an argument to the decorating class: @vendor_stream_wrapper.asset.css.optimizer_decorator.inner. The name of this inner service is automatically based on the new decorator service, you can provide an alternative by setting the decoration_inner_name option.

...
  vendor_stream_wrapper.asset.css.optimizer_decorator:
    class: 'Drupal\vendor_stream_wrapper\Asset\VendorStreamWrapperAssetOptimizer'
    decorates: asset.css.optimizer
    decoration_priority: 1
    public: false
    arguments: ['@vendor_stream_wrapper.asset.css.optimizer_decorator.inner']
...

The example used here is based on the contributed Vendor Stream Wrapper module, which provides a vendor:// stream wrapper to include CSS or JavaScript files that are offered by libraries in the vendor directory. Typically, the vendor directory is located outside the web-root and therefore not publicly accessible, to allow assets to still be available easily/loaded by browsers the vendor:// stream wrapper comes in handy and works similar to the private:// and temporary:// stream wrappers. The problem here is that the virtual paths introduced by this module cannot be used correctly by Drupal's asset optimization service in order to load the contents of the files using the virtual paths. Therefore, the solution is to introduce a service decorator for both the asset.css.optimizer and asset.js.optimizer services (the latter is not shown here, but similar to the service registration depicted above), which in short transforms the (virtual) paths for assets starting with vendor_files/ to the correct file location in the vendor directory.

The VendorStreamWrapperAssetOptimizer class is provided below and implements the same interface as the original class for the asset.css.optimizer (and asset.js.optimizer) service (i.e., AssetOptimizerInterface). The original asset optimization service is provided as the $inner_service argument to the constructor and allows to ensure that the existing functionality can be used correctly (even when it would be changed by other service decorators). The only thing we have done is to transform the virtual paths to the correct file location in the optimize() method.

<?php

namespace Drupal\vendor_stream_wrapper\Asset;

use Drupal\Core\Asset\AssetOptimizerInterface;
use Drupal\vendor_stream_wrapper\StreamWrapper\VendorStreamWrapper;

/**
 * Decorates the CSS and JS optimization services.
 *
 * The optimization services loads the CSS and JS files based on the provided
 * path, however, the path that is generated is virtual/non-existing (which
 * prevents the whole vendor folder being publicly available). Therefore, we
 * need to decorate the optimization service, used when aggregating CSS and JS
 * files, to set the correct file path for the CSS and JS assets in the vendor
 * folder.
 */
class VendorStreamWrapperAssetOptimizer implements AssetOptimizerInterface {

  /**
   * The original asset optimization service instance that is being decorated.
   *
   * @var \Drupal\Core\Asset\AssetOptimizerInterface
   */
  protected $innerService;

  /**
   * Creates a new VendorStreamWrapperCssOptimizer instance.
   *
   * @param \Drupal\Core\Asset\AssetOptimizerInterface $inner_service
   *   The original asset optimization service instance that is being decorated.
   */
  public function __construct(AssetOptimizerInterface $inner_service) {
    $this->innerService = $inner_service;
  }


  /**
   * {@inheritdoc}
   */
  public function optimize(array $asset) {
    // Translate the virtual 'vendor_files' paths into the correct path for the
    // vendor directory, so that the actual file can loaded by the optimization
    // service.
    if (!empty($asset['data']) && strpos($asset['data'], 'vendor_files/') === 0) {
      $asset['data'] = str_replace('vendor_files', VendorStreamWrapper::basePath(), $asset['data']);
    }

    return $this->innerService->optimize($asset);
  }

  /**
   * {@inheritdoc}
   */
  public function clean($content) {
    return $this->innerService->clean($content);
  }

}

Note that you shouldn't extend the original's service class, but instead implement each method and invoke it's functionality using $this->innerService. In different source on the internet this is provided as an example, but it prevents correct working when multiple decorators are applied to one service. When a lot of methods have to be overloaded without introducing/changing functionality, like the clean() method in the example above, you can instead use the magic __call() method as shown below:

...
  /**
   * Direct all non-overloaded method invocations to the original/inner service.
   *
   * @param $name
   *   Name of the method being called.
   * @param $arguments
   *   Enumerated array containing the parameters passed to the method.
   *
   * @return mixed
   *   Result of the original services that is being decorated.
   */
  public function __call($name, $arguments) {
    return call_user_func_array(array($this->innerService, $name), $arguments);
  }
...
Category
Drupal
Decorator pattern
Services

Plain text

  • No HTML tags allowed.
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.