Drupal service container deep dive (Part 3): service collectors

This article is the third part of a series exploring the Drupal service container, focusing on service collectors.

# drupal # php # dependency injection # autowiring # service container

Disclaimer: Our Tech Blog is a showcase of the amazing talent and personal interests of our team. While the content is reviewed for accuracy, each article is a personal expression of their interests and reflects their unique style. Welcome to our playground!

Drupal Service Container deep dive, part 3

In the first part and second part of this series, we explored the basics of the Drupal service container like tags, compiler passes, service providers, autoconfiguration, aliases, autowiring, and named arguments. In this third part, we will explore service collectors, which aggregate multiple services into a single service for streamlined access.

Service Collectors

When we talked about service tags, we mentioned that they are a way to add metadata to services in the service container, and that we need to write a compiler pass to process them.

Drupal provides a shortcut for a common use case of service tags called service collectors. A service collector can be used to automatically collect all services tagged with a specific tag and inject them into another service. This is particularly useful when you have a set of related services that you want to manage collectively.

For example, every time you want to add a new Twig extension in Drupal, you must tag your service with the twig.extension tag, like this:

services:
  my_module.my_twig_extension:
    class: Drupal\my_module\Twig\MyTwigExtension
    tags:
      - { name: twig.extension }

The twig service collector will automatically gather all services with this tag and add them to the Twig environment. Here’s how the twig service is defined in core.services.yml using a service collector:

twig:
  class: Drupal\Core\Template\TwigEnvironment
  arguments: ['%app.root%', '@cache.default', '%twig_extension_hash%', '@state', '@twig.loader', '%twig.config%']
  tags:
    - { name: service_collector, tag: 'twig.extension', call: addExtension }

In this example, the twig service is defined with a service_collector tag. This tag specifies that all services tagged with twig.extension should be collected and added to the Twig environment using the addExtension method.

In the TwigEnvironment class, the addExtension method is called for each collected service:

public function addExtension(ExtensionInterface $extension)
{
    $this->extensionSet->addExtension($extension);
    [...]
}

The call attribute in the service_collector tag can be omitted, in which case a default method named addHandler will be used. In the same way, if the tag attribute is omitted, the name of the service being defined (in this case twig) will be used as the tag name to collect services.

Advanced Usage

If you need to add tagged services to a service collector in a specific order, you can use the priority attribute in the tag definition. Services with higher priority values will be added first. For example:

services:
  my_module.first_twig_extension:
    class: Drupal\my_module\Twig\FirstTwigExtension
    tags:
      - { name: twig.extension, priority: 10 }
  my_module.second_twig_extension:
    class: Drupal\my_module\Twig\SecondTwigExtension
    tags:
      - { name: twig.extension, priority: 5 }

If you need to know the id of each tagged service when adding it to the collector, you can use the id attribute. This will pass the service id as an additional argument to the specified method:

my_module.custom_collector:
  class: Drupal\my_module\CustomCollector
  tags:
    - { name: service_collector, tag: custom.tag, call: addService, index_by: id }

Finally, every other attribute you add to the service_collector tag will be passed as additional arguments to the specified method. For example, if you want to pass a custom argument along with each tagged service, you can do so like this:

my_module.custom_collector:
  class: Drupal\my_module\CustomCollector
  tags:
    - { name: service_collector, tag: custom.tag, call: addService, custom_arg: 'value' }

The code in the CustomCollector class would look like this:

public function addService(MyService $service, string $custom_arg): void {
    // Your logic here
}

The order of the arguments in the tag and in the method signature is not important, as long as the names match.

If your service collector needs at least one tagged service to function correctly, you can enforce this requirement by adding the required: true attribute to the service_collector tag:

twig.loader:
  class: Twig\Loader\ChainLoader
  public: false
  tags:
    - { name: service_collector, tag: twig.loader, call: addLoader, required: TRUE }

This ensures that if no services are tagged with twig.loader, the service container will throw an error, preventing potential runtime issues.

Injecting services into a service collector is an heavy operation because the service container needs to create an instance for all the tagged services before it can inject them into the collector. A better approach is to use a service_id_collector, that collects the service ids instead of the actual services.

Service ID Collectors

When dealing with a large number of services, injecting all of them into a service collector can lead to performance issues, as the service container has to instantiate each service before injecting it. To mitigate this, Drupal provides the concept of service ID collectors. With service id collectors, the services are only created when they are actually needed, like in:

theme.negotiator:
  class: Drupal\Core\Theme\ThemeNegotiator
  arguments: ['@access_check.theme', '@class_resolver']
  tags:
    - { name: service_id_collector, tag: theme_negotiator }

The list of service ids is injected into the ThemeNegotiator service using constructor injection:

/**
 * Constructs a new ThemeNegotiator.
 *
 * @param \Drupal\Core\Theme\ThemeAccessCheck $theme_access
 *   The access checker for themes.
 * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
 *   The class resolver.
 * @param string[] $negotiators
 *   An array of negotiator IDs.
 */
public function __construct(ThemeAccessCheck $theme_access, ClassResolverInterface $class_resolver, array $negotiators) {
  ...
}

In the previous example, the ThemeNegotiator service constructor receives all the defined arguments (@access_check.theme and @class_resolver) and an array of service ids for all services tagged with theme_negotiator. A service collector like this usually requires the use of a service locator to lazily load the actual services when needed:

public function determineActiveTheme(RouteMatchInterface $route_match) {
  foreach ($this->negotiators as $negotiator_id) {
    $negotiator = $this->classResolver->getInstanceFromDefinition($negotiator_id);
  }
}

This approach optimizes performance by avoiding the instantiation of all tagged services upfront.

As we saw in Part 2, a service locator is a kind of an anti-pattern and should be used sparingly. We’ll see in the next section how this has been improved in modern Drupal versions.

AutowireIterator

Since Symfony 7.0 (and hence Drupal 11.0), you can also use the AutowireIterator attribute to inject all services tagged with a specific tag, like in this example:

class CachedDiscoveryClearer implements CachedDiscoveryClearerInterface {

  /**
   * Constructs the CachedDiscoveryClearer service.
   *
   * @param \Traversable $cachedDiscoveries
   *   The cached discoveries.
   */
  public function __construct(
    #[AutowireIterator(tag: 'plugin_manager_cache_clear')]
    protected \Traversable $cachedDiscoveries,
  ) {}
}

The AutowireIterator attribute allows you to specify a tag name, and the service container will automatically inject all services tagged with that name into the constructor as a \Traversable object.

Using AutowireIterator fixes both the performance issues of service_collector and the anti-pattern of service_id_collector, as services are only instantiated when iterated over.

Newer versions of Drupal are gradually implementing all the features of Symfony’s service container, reducing the need for Drupal-specific solutions.

All the features available in Symfony can be used on Drupal as well, like the ability to exclude specific services with the exclude parameter, or to set the priority of a service directly on the service class, with the AsTaggedItem attribute:

use Symfony\Component\DependencyInjection\Attribute\AsTaggedItem;

#[AsTaggedItem(index: 'handler_one', priority: 10)]
class One {
    // ...
}

Luckily, all the documentation explained in the Symfony documentation applies to Drupal as well.

Note: AutowireIterator and AsTaggedItem attributes are only available in modules that declare autowiring: true in their module_name.services.yml file.

Conclusion

Service collectors and AutowireIterators are powerful tools in Drupal’s service container that help manage and organize related services efficiently. By leveraging these features, developers can create more modular and maintainable code, while also optimizing performance.

Stay tuned for the next parts of this series, where we will continue to explore more advanced features of the Drupal service container:

  • Part 1 covers service tags, compiler passes, service providers, and autoconfiguration, laying the groundwork for understanding how services are defined and managed within the container.
  • Part 2 delves into aliases, autowiring, and name arguments, demonstrating how to resolve and inject dependencies based on type hints, reducing boilerplate code and enhancing maintainability.
  • Part 4 will explore factories, which provide a mechanism for creating services with complex initialization logic.
  • Part 5 will discuss backend overrides, enabling developers to customize service implementations for specific environments or use cases.
  • Part 6 will examine advanced features of the Drupal service container, such as service decoration and lazy loading.