When you dive into Drupal development, one of the first concepts you learn about is Services—along with other concepts such as Dependency Injection, which is tightly related to Services.

The concept of Services was introduced after the overhaul and re-architecture of Drupal 7 into Drupal 8, where the paradigm shifted to Object Oriented Programming (OOP). Services have become one of the foundation stones of Drupal core, allowing us to use the already defined services to interact with Drupal.

Services are any object managed by the services container. Their main goal is to decouple reusable functionality by making these objects pluggable and replaceable by registering them with a service container

But Services can also be decorated to offer more functionality and flexibility through service tags. Let’s learn about them!

Service Tags

In Drupal, tags are used to indicate that a service should be registered or used in some special way, or that it belongs to a category. For example, when creating an Event Subscriber, you’ll tag your service with the event_subscriber tag. This indicates the service is an event subscriber that needs to be triggered and handled by the Event Subscriber service collector.

Service tags introduce a way to group services based on shared functionalities or characteristics. This allows Drupal to dynamically retrieve and gather services belonging to the same category.

Drupal is flexible enough to support custom service tags. And lucky for us, creating a custom tag is pretty simple! 

Custom Service Tags

To create custom service tags you need a service collector and a common interface that the tagged services must implement. In this guide, we’ll use the example of creating a food reviewer module with its own custom service tag.

Common Interface

All the services that will use the custom service tag must implement a common interface that specifies the methods all tagged services must have. This common interface guarantees that all services grouped under the same tag category maintain consistent functionalities and behaviors.

For the food reviewer services, they need to implement the following interface:

<?php

namespace Drupal\food_review;

/**
* Interface for food reviewer services.
*/
interface FoodReviewerInterface {

 /**
  * Get the food host.
  *
  * @return string
  *   The host name.
  */
 public function getHost(): string;

 /**
  * Set the food host.
  *
  * @param string $host
  *   The host name.
  */
 public function setHost(string $host): void;

 /**
  * Reviews a dish.
  *
  * @param string $dish
  *   The dish id.
  *
  * @return int
  *   A review score from 0 to 10.
  */
 public function review(string $dish_id): int;

}

Service Collector

service collector, as the name indicates, collects and processes all the services for a specific tag. The service collector is a normal PHP object that must have at least one method; this method is the one in charge of processing the tagged services, one by one.

The method should accept the following parameters:

  1. Common interface (required) – represents the discovered service itself and it must be type-hinted with the common interface related to the service tag 
  2. ID (optional) – the ID of the tagged service 
  3. Priority (optional) – the priority of the tagged service
  4. Other custom parameters (optional) – you can set other parameters you deem your tag service needs

For our use case, we create a food reviewer collector:

<?php

namespace Drupal\food_review;

/**
* The food reviewer manager.
*/
class FoodReviewerManager {

 /**
  * The tagged reviewer services.
  *
  * The array is divided by category and priority.
  *
  * @var array
  */
 protected array $reviewers;

 /**
  * Adds a food reviewer service to the reviewers property.
  *
  * @param \Drupal\food_review\FoodReviewerInterface $food_reviewer_service
  *   The food reviewer service.
  * @param string $id
  *   The service ID.
  * @param int $priority
  *   The service priority.
  * @param string $category
  *   The food review category.
  */
 public function addReviewer(FoodReviewerInterface $food_reviewer_service, string $id, int $priority, string $category): void {
   $food_reviewer_service->setHost($this->getHostByCategory($id, $category));
   $this->reviewers[$category][$priority][] = $food_reviewer_service;
 }

 /**
  * Get the reviewer host by category.
  *
  * @param string $id
  *   The service ID.
  * @param string $category
  *   The service category.
  *
  * @return string
  *   The host name.
  */
 private function getHostByCategory(string $id, string $category): string {
   // ...
   return $host;
 }

}

For this collector, we add the reviewer service itself, the service ID, the priority, and a custom property named category. We also add a new step when discovering a tagged service—in this case, adding the host.

Once the service collector is created, we must add it into the service container so it can be discoverable by Drupal. A service collector is a simple service—meaning that in order for it to be discovered, you only need to add it into services YAML and tag it.

services:
 food_review.food_reviewer_manager:
   class: Drupal\food_review\FoodReviewerManager
   tags:
 	- { name: service_collector, tag: food_review.food_reviewer, call: addReviewer }

When tagging a service with the service_collector tag, besides making the service a custom service collector, you need to provide the following properties:

  1. Tag – the custom service collector tag name; if not provided, the default service ID will be used as the tag.
  2. Call – the method that needs to be executed when discovering a service tagged with our own tag; it defaults to ‘addHandler’ if not provided.
  3. Required – defaults to false; otherwise, at least one service with the custom tag must exist.

If your service collector depends on other services, you can inject them as usual. Remember a service collector is just a simple tagged service.

Now that we have the custom service tag, let’s create a tagged service—in our example, a food reviewer.

<?php

namespace Drupal\food_review;

/**
* A sea food reviewer service.
*/
class SeaFoodReviewer implements FoodReviewerInterface {

 /**
  * The sea food host.
  *
  * @var string
  */
 protected string $host;

 /**
  * {@inheritdoc}
  */
 public function getHost(): string {
   return $this->host;
 }

 /**
  * {@inheritdoc}
  */
 public function setHost(string $host): void {
   $this->host = $host;
 }

 /**
  * {@inheritdoc}
  */
 public function review(string $dish_id): float {
   // ...
   return $score;
 }

}

As you can see, the service implemented the FoodReviewerInterface and all the methods.

The last step is creating and tagging the service in Drupal:

services:
 food_review.food_reviewer_manager:
   class: Drupal\food_review\FoodReviewerManager
   tags:
 	- { name: service_collector, tag: food_review.food_reviewer, call: addReviewer }

 food_review.seafood_reviewer:
   class: Drupal\food_review\SeaFoodReviewer
   tags:
 	- { name: food_review.food_reviewer, priority: 1, category: seafood }

Now the service has been created! We correctly tagged it with our own custom service tag, and added the priority as well as the category properties.

Using the Service Collector and Service Tag

Now that we’ve created the service collector and used the service tag to label services we want to categorize, we can customize and use the service collector.

One of the benefits of using service tags is that the service collector can receive multiple processor services or their IDs during instantiation. This helps in establishing an extensible architecture where the service collector can interact with multiple processors (like the tagged services) without being tightly coupled to them.

Also, the service collector receives instances of all registered processors when it is instantiated, making them immediately available. These processors are added dynamically at runtime. This allows for more flexibility when modifying or extending the behavior of the collector service or the processors themselves.

In our example above, when a processor is added, we set the processor host through a mechanism that belongs to the service collector itself—removing the responsibility from the processor and making the service collector do the heavy lifting. So, when new food reviewers are added, they only need to be tagged and other processes (such as setting the host) can be delegated to the service collector.

But the example above is very simple and it doesn’t take into account the benefit of having all the services grouped. 

So, let’s assume we need to review a dish by a category, and we need to retrieve all the reviews of all the available reviewers. What we can do is add that functionality in the service collector:

<?php

namespace Drupal\food_review;

/**
* The food reviewer manager.
*/
class FoodReviewerManager {

 /**
  * The tagged reviewer services.
  *
  * The array is divided by category and priority.
  *
  * @var array
  */
 protected array $reviewers;

 // ...

 // ...

 /**
  * Processes a dish review by category.
  *
  * All the available food reviewers for the category will be processed.
  *
  * @param string $category
  *   The food category.
  * @param string $dish_id
  *   The dish to review ID.
  *
  * @return array
  *   The reviews of the dish are split by priority.
  */
 public function processReviewsByCategory(string $category, string $dish_id): array {
   $reviews = [];

   foreach ($this->reviewers[$category] as $priority => $reviewers) {
 	/** @var \Drupal\food_review\FoodReviewerInterface $reviewer */
 	foreach ($reviewers as $reviewer) {
   	$reviews[$priority][] = [
     	'host' => $reviewer->getHost(),
     	'review_score' => $reviewer->review($dish_id),
   	];
 	}
   }

   return $reviews;
 }

}

In the method above, we iterate through the food reviewers of the chosen category and retrieve the review for each of the available reviewers. We can iterate through the reviewers because the service collector has them.

If, for example, we need to run the code above to calculate the average review of a dish, we can use the service collector:

/**
 * Calculates the average of a dish.
 */
function _food_review_calculate_average(string $category, string $dish_id) {
 /** @var \Drupal\food_review\FoodReviewerManager */
  $food_manager = \Drupal::service('food_review.food_reviewer_manager');
  $reviews = $food_manager->processReviewsByCategory($category, $dish_id);
  // ...
  // ...
  return $average;
}

Of course, it would make more sense to add the average calculation method in the service collector, but this is only a simple example. 

As you can see, we use the service collector as a normal service, then call the method to get the reviews. The service collector can get all the reviews because it has all the tagged services. 

Most Drupal Core service collectors typically operate by iterating through tagged services and providing them with data they might need to perform specific tasks. 

For instance, let's consider the example of \Drupal\Core\Cache\CacheTagsInvalidator, which is a service collector tagged with the name cache_tags_invalidator. This collector is responsible for handling cache tag invalidation in Drupal. It iterates through all the available tag invalidator processors associated with the cache_tags_invalidator tag.

During this iteration, the CacheTagsInvalidator service calls the invalidateTags method from the CacheTagsInvalidatorInterface interface. This method typically requires information about cache tags that are passed to the service collector itself.

As a result, the array of cache tags provided to the service collector is then passed to each tag invalidator processor through the invalidateTags method. This enables these processors to perform their specific tasks related to cache tag invalidation using the provided data.

Caveats to Consider

Using the example above, what would happen if you used a food reviewer service itself? In our case, the food_review.seafood_reviewer.

Well, you’d be able to inject it or fetch it from the service container. But would the host property be available?

If the service collector has not been instantiated at the moment the seafood service is instantiated, then all the code that is run when a tagged service is added into the service collector has not yet run. So an error would be thrown if you were to call the \Drupal::service('food_review.seafood_reviewer')->getHost().

The error is due to the $host property not being instantiated yet, because the property is instantiated when the service collector is instantiated. Naturally, the code could be refactored to account for these cases (for example, checking first whether the property is already set, and so on). The opposite scenario would work, though.

Remember, the code in the service collector is not run upon service discovery but until the service collector itself has been instantiated (i.e. when we make use of it).

Although there’s no best practices regarding service tags, I believe that tagged services shouldn’t be used (or at least not invoked) through the service container. Instead, the interaction should be through the service tag collector. That way, we can minimize side effects. If we need to use a tagged service, retrieving it from the collector ensures that whatever transformation that needs to be done through the collector is already done. The evidence in Drupal core seems to suggest that the tagged services are not invoked through the service container, only through the collector.

The Final Word on Service Tags

Service tags offer a powerful way to categorize services within Drupal based on shared functionalities or traits. By assigning tags to services, developers can group related services together for dynamic retrieval—allowing for more flexible and adaptive system behavior.

This is particularly useful in scenarios where the system needs to adapt based on specific functionalities or characteristics. For example, service tags allow for efficient categorization and collective processing where various services share a common functionality or behavior but differ in implementation. 

This mechanism empowers developers to perform actions or tasks based on these grouped services—helping to create streamlined and adaptable workflows within the Drupal ecosystem.

Of course, service tags have their caveats. The decision to use them must fit the project’s architectural requirements, in the same way that coupling should be avoided, due to the nature of how the service collectors work. If needed, Drupal offers other architectural approaches that take into account these caveats.

I hope you found this guide enlightening and helpful. Happy coding!