Tagged services in Drupal 8

We saw how to write a simple service container in Drupal earlier. We shall build a tagged service now. To illustrate a proper use case for tagged services, let's contrive a scenario where you add a pipeline custom filters to user text before rendering it on the page.

First, clone the code which will be used in this post.

$ git clone git@github.com:drupal8book/process_text.git

Checkout the first version of the code where we take custom text from user, process and display it in a page without using services.

$ cd process_text
$ git checkout -f just-filtering

We get custom text from a user using a config form.

class CustomTextSettingForm extends ConfigFormBase {
  /**
   * {@inheritdoc}
   */
  protected function getEditableConfigNames() {
    return [
      'process_text.settings',
    ];
  }
  /**
   * {@inheritdoc}
   */
  public function getFormId() {
    return 'custom_text_setting_form';
  }
  /**
   * {@inheritdoc}
   */
  public function buildForm(array $form, FormStateInterface $form_state) {
    $config = $this->config('process_text.settings');
    $form['custom_text'] = [
      '#type' => 'textarea',
      '#title' => $this->t('Custom Text'),
      '#default_value' => $config->get('custom_text'),
    ];
    return parent::buildForm($form, $form_state);
  }
  /**
   * {@inheritdoc}
   */
  public function validateForm(array &$form, FormStateInterface $form_state) {
    parent::validateForm($form, $form_state);
  }
  /**
   * {@inheritdoc}
   */
  public function submitForm(array &$form, FormStateInterface $form_state) {
    parent::submitForm($form, $form_state);
    $this->config('process_text.settings')
      ->set('custom_text', $form_state->getValue('custom_text'))
      ->save();
    $form_state->setRedirect('process_text.show');
  }
}

We save it as a piece of configuration called process_text.settings.custom_text.

Before rendering this text, let's say you would want to:

  • Remove any <div> tags.
  • Substitute a token [greeting] with <span class"greeting">hello world</span> throughout the text.

We get the text and do all the above processing inside a custom controller.

class ProcessTextController extends ControllerBase {
  /**
   * Processtext.
   *
   * @return string
   *   Return processed custom text.
   */
  public function processText() {
    $custom_text = \Drupal::config('process_text.settings')->get('custom_text');
    // do processing
    // remove divs
    $custom_text = str_replace(["<div>", "</div>"], "", $custom_text);
    // replace greeting tokens
    $custom_text = str_replace("[greeting]", '<span class="greeting">hello world</span>', $custom_text);
    return [
      '#type' => 'markup',
      '#markup' => $custom_text
    ];
  }
}

This is good, but we could do better. What if we change the filter applying mechanism? We have to change this code. Instead, let's convert it into a service.

$ cd process_text
$ git checkout -f services-first-cut

Our text filter service takes a set of filters and applies them to a given text when we call applyFilters.

class TextFilterService {
  private $filters = [];
  /**
   * @param Filter $filter
   */
  public function addFilter(Filter $filter) {
     $this->filters[] = $filter;
  }
    /**
     * applies all filters to given text and returns
     * filtered text.
     *
     * @param string $txt
     *
     * @return string
     */
  public function applyFilters($txt) {
    foreach ($this->filters as $filter) {
      $txt = $filter->apply_filter($txt);
    }
    return $txt;
  }
}

We need to crate a services.yml file for the above service.

services:
  process_text.text_filter:
    class: Drupal\process_text\TextFilterService

Here's how the processText function text looks now.

public function processText() {
  $custom_text = \Drupal::config('process_text.settings')->get('custom_text');
  // do processing using a service
  $filter_service = \Drupal::service('process_text.text_filter');
  // remove divs
  $filter_service->addFilter(new RemoveDivs());
  // substitute greeting token
  $filter_service->addFilter(new Greeting());
  // apply all the above filters
  $custom_text = $filter_service->applyFilters($custom_text);
  return [
    '#type' => 'markup',
    '#markup' => $custom_text
  ];
}

Now the filter applying mechanism is swappable. We can add write a different functionality and inject that implementation using service containers.

Now, what if we want to add a new filter to this code, like, enclosing the whole text within a <p> tag.

Sure. We could do that.

Let's checkout the specific tag where we add a new filter.

$ cd process_text
$ git checkout -f add-new-filter

We build that filter.

class EnclosePTags implements Filter {
  public function apply_filter($txt) {
    return '<p>'. $txt . '</p>';
  }
}

…and add it to the set of filters being applied.

public function processText() {
  $custom_text = \Drupal::config('process_text.settings')->get('custom_text');
  // do processing using a service
  $filter_service = \Drupal::service('process_text.text_filter');
  // remove divs
  $filter_service->addFilter(new RemoveDivs());
  // substitute greeting token
  $filter_service->addFilter(new Greeting());
  // Enclose p tags
  $filter_service->addFilter(new EnclosePTags());
  // apply all the above filters
  $custom_text = $filter_service->applyFilters($custom_text);
  return [
    '#type' => 'markup',
    '#markup' => $custom_text
  ];
}

How about injecting the filter adding mechanism itself? Wouldn't it be cool if we are able to add new filters without changing this part of the code? Not to mention the fact that the code will be more testable than before if we follow this approach. This is exactly what tagged services help us accomplish.

Let's write each filter as a tagged service.

$ cd process_text
$ git checkout -f tagged-services

Here's how our process_text.services.yml looks now.

services:
  process_text.text_filter:
    class: Drupal\process_text\TextFilterService
    tags:
      - { name: service_collector, tag: text_filter, call: addFilter }

  remove_divs_filter:
    class: Drupal\process_text\TextFilter\RemoveDivs
    tags:
      - { name: text_filter }

  greeting_filter:
    class: Drupal\process_text\TextFilter\Greeting
    tags:
      - { name: text_filter }

  enclose_p_filter:
    class: Drupal\process_text\TextFilter\EnclosePTags
    tags:
      - { name: text_filter }

There are many changes here. Firstly, all the filters have been converted to services themselves. The have a common tag called text_filter. The main service also has a few changes. It has a tag called service_collector and a tag parameter call. This ritual of creating a service container and adding a set of tagged services is such a common pattern that Drupal 8 has a special tag to do this, called the service_collector. This tag takes an additional parameter called call which indicates what function has to be called in the service to add all the tagged services.

What happens is, Drupal's TaggedHandlersPass picks up all services with "service_collector" tag, finds services which have the same tag as that of this service(text_filter in our case) and calls the method in call to consume the tagged service definition. If you're coming from Symfony world, this might seem familiar for you. In order to execute some custom code, like applying a set of filters, we implement CompilerPassInterface, which is run whenever the service cotainer(ApplyFilter in our case) is being built. You can find more about CompilerPassInterface here.

Your controller code looks a lot simpler now.

public function processText() {
  $custom_text = \Drupal::config('process_text.settings')->get('custom_text');
  // do processing using a service
  $filter_service = \Drupal::service('process_text.text_filter');
  $custom_text = $filter_service->applyFilters($custom_text);
  return [
    '#type' => 'markup',
    '#markup' => $custom_text
  ];
}

Now, all you need to add new filters is to update the service yaml file with the new filter service and tag it with "text_filter" tag.

Tagged services in the wild

Drupal allows developers to add a new authentication mechanism using tagged services. The authentication_collector is defined in core.services.yml.

authentication_collector:
  class: Drupal\Core\Authentication\AuthenticationCollector
  tags:
    - { name: service_collector, tag: authentication_provider, call: addProvider }

To add a new authentication provider, one has to implement the AuthenticationProviderInterface and flesh out the applies and authenticate functions. This will be the subject of another post.

Here's how the addProvider function looks like:

public function addProvider(AuthenticationProviderInterface $provider, $provider_id, $priority = 0, $global = FALSE) {
  $this->providers[$provider_id] = $provider;
  $this->providerOrders[$priority][$provider_id] = $provider;
  // Force the providers to be re-sorted.
  $this->sortedProviders = NULL;

  if ($global) {
    $this->globalProviders[$provider_id] = TRUE;
  }
}

And here's how we would register our hypothetical authentication provider service.

services:
  authentication.custom_auth:
    class: Drupal\custom_auth\Authentication\Provider\CustomAuth
    tags:
      - { name: authentication_provider }

Another example is the breadcrumb manager service.

breadcrumb:
  class: Drupal\Core\Breadcrumb\BreadcrumbManager
  arguments: ['@module_handler']
  tags:
    - { name: service_collector, tag: breadcrumb_builder, call: addBuilder }

To add breadcrumbs from your module, you would need to implement BreadcrumbBuilderInterface and add the following entry to your services.yml,

services:
  foo.breadcrumb:
    class: Drupal\foo\MyBreadcrumbBuilder
    tags:
      - { name: breadcrumb_builder, priority: 100 }

The BreadcrumbManager::addBuilder collects all breadcrumb bilders and builds it using the BreadcrumbManager::build function.

public function addBuilder(BreadcrumbBuilderInterface $builder, $priority) {
  $this->builders[$priority][] = $builder;
  // Force the builders to be re-sorted.
  $this->sortedBuilders = NULL;
}