Definition

A Drupal 8 application has several objects, some of which specialize in the performance of specific tasks across the system, e.g. logging, mailing, authentication, etc. There might even be a simple object with a couple of methods to be shared by other classes. These objects are called services. Then, there is a service container which is a special object that holds or contains all the services in the application.

The Symfony Dependency Injection Component takes this concept further by coordinating the creation of objects so that the management of dependencies is centralized in a standard manner.

Creation

In order to extend Drupal functionality, you require a module. For example, the Country module provides a field that allows users to select a country either from a select option or an autocomplete widget. You may also limit the countries that are available for selection.

The list of selectable countries is required in different classes in the module. An option is to define a service that will provide this functionality. Interestingly, there is a related service in Drupal core that provides a list of all countries in the world. It makes sense to use this list in the Country field module.

Firstly, we write the class to use as a service.

Class file

You do not need an interface, but we will start with one.


<?php
// src/CountryManagerInterface.php

namespace Drupal\country;

use Drupal\Core\Field\FieldDefinitionInterface;

interface CountryManagerInterface {

  /**
   * Get array of selectable countries.
   *
   * If some countries have been selected at the default field settings, allow
   * only those to be selectable. Else, check if any have been selected for the
   * field instance. If none, allow all available countries.
   *
   * @param \Drupal\Core\Field\FieldDefinitionInterface $field_definition
   *  The field definition object.
   *
   * @return array
   *   Array of country names keyed by their ISO2 values.
   */
  public function getSelectableCountries(FieldDefinitionInterface $field_definition);
}

In our implementation of this interface, we also need to provide a dependency on the core service that provides a list of all countries. Since our class absolutely depends on the service, we pass in the CountryManagerInterface from core as a constructor argument of our class:

<?php
// src/CountryManager.php

namespace Drupal\country;

use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Locale\CountryManagerInterface as CountryListManagerInterface;
use Drupal\Core\Field\FieldDefinitionInterface;

/**
 * Defines a class for country field management.
 */
class CountryManager implements CountryManagerInterface, ContainerInjectionInterface {

  /**
   * @var \Drupal\Core\Locale\CountryManagerInterface $country_list_manager
   */
  protected $countryListManager;

  /**
   * Constructs a new CountryManager object.
   *
   * @param \Drupal\Core\Locale\CountryManagerInterface $country_list_manager
   */
  public function __construct(CountryListManagerInterface $country_list_manager) {
    $this->countryListManager = $country_list_manager;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('country_manager')
    );
  }

  /**
   * @inheritdoc
   */
  public function getSelectableCountries(FieldDefinitionInterface $field_definition) {
    $field_definition_countries = $field_definition->getSetting('selectable_countries');
    $field_storage_countries = $field_definition->getFieldStorageDefinition()->getSetting('selectable_countries');

    $countries = $this->countryListManager->getList();

    $allowed = (!empty($field_definition_countries)) ? $field_definition_countries : $field_storage_countries;
    return  (!empty($allowed)) ? array_intersect_key($countries, $allowed) : $countries;
  }
}

We decided to call our class CountryManager but there is a core class by the same name. Namespaces allow us to have both. However, we have to alias the interface from core to differentiate it from ours:

use Drupal\Core\Locale\CountryManagerInterface as CountryListManagerInterface;

As the constructor depends on the CountryListManagerInterface, we need an instance from the service container. That is why our class must implement the ContainerInjectionInterface. Its create() method ensures that we get an object with the id of country_manager from the core service when this constructor is called.

Our getSelectableCountries() method gets a list of all countries from the core service. It checks the field and instance settings to determine whether the list should be limited. If some selectable countries have been configured, then the list is filtered accordingly before it is returned to the caller.

We now have a class. How do we tell Drupal and the service container about it?

Configuration file

We need a YAML file, located in the main module directory with the info file, named according to the pattern of MODULE_MACHINE_NAME.services.yml:

# country.services.yml
services:
  country.field.manager:
    class: Drupal\country\CountryManager
    arguments: ['@country_manager']

The id of the service is country.field.manager and we provide our CountryManager as the value of the class key. The arguments key is not always required, but we need to inject the core CountryManager service into ours. The id of that other service is country_manager, and the "@" prefix tells the container that this argument is an existing service. See core/core.services.yml for its configuration.

Utilization

There are two ways of using a service:

  1. Implement the ContainerInjectionInterface and inject the country.field.manager service as a dependency.

  2. Get the service from the global static service container wrapper provided by Drupal e.g. $countries = \Drupal::service('country_manager')->getList();.

Then, you can call any method provided by the service, e.g. getSelectableCountries(). The first approach is the recommended way. This is an example demonstrating both ways:

<?php

// src/Controller/CountryAutocompleteController.php

namespace Drupal\country\Controller;

use Drupal\country\CountryManagerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\field\Entity\FieldConfig;
use Drupal\Component\Utility\UrlHelper;

/**
 * Returns autocomplete responses for countries.
 */
class CountryAutocompleteController implements ContainerInjectionInterface {

  /**
   * @var \Drupal\country\CountryManagerInterface
   */
  protected $countryManager;

  /**
   * Constructs a new CountryManager object.
   *
   * @param \Drupal\country\CountryManagerInterface $country_manager
   */
  public function __construct(CountryManagerInterface $country_manager) {
    $this->countryManager = $country_manager;
  }

  /**
   * {@inheritdoc}
   */
  public static function create(ContainerInterface $container) {
    return new static(
      $container->get('country.field.manager')
    );
  }

  /**
   * Returns response for the country name autocompletion.
   *
   * @param \Symfony\Component\HttpFoundation\Request $request
   *   The current request object containing the search string.
   * @param string $entity_type
   *   The type of entity that owns the field.
   * @param string $bundle
   *   The name of the bundle that owns the field.
   * @param $field_name
   *   The name of the field.
   *
   * @return \Symfony\Component\HttpFoundation\JsonResponse
   *   A JSON response containing the autocomplete suggestions for countries.
   */
  public function autocomplete(Request $request, $entity_type, $bundle, $field_name) {
    $matches = array();
    $request_uri = $request->getRequestUri();
    $parsed = UrlHelper::parse($request_uri);
    if (!empty($parsed['query'])) {
      $string = $parsed['query']['q'];

      // Check if the bundle is global - in that case we need all of the countries
      if($bundle == 'global') {
        $countries = \Drupal::service('country_manager')->getList();
      }
      else {
        // Get field config
        $field_definition = FieldConfig::loadByName($entity_type, $bundle, $field_name);
        $countries = $this->countryManager->getSelectableCountries($field_definition);
      }

      foreach ($countries as $iso2 => $country) {
        $label = $country->render();
        if (stripos($label, $string) !== FALSE) {
          $matches[] = array('value' => $label, 'label' => $label);
        }
      }
    }
    return new JsonResponse($matches);
  }
}

Conclusion

The concept of services is not complicated. It is yet another way of organizing code for easier management and control of the initialization of objects throughout an application.

What is your experience with services in Drupal 8 like? Let us know in the comments below.