Drupal 8 API: services and the dependency-injection Container

Pre-requisites

Dependency injection inverts how you create helper objects

When we're writing some method on some object, and we need some helper object, to do work for the current object, the naive way of creating the helper is:

class MyClass {
  public function doSomething() {
    $helper = new Helper();
  }
}

However, it's now very hard to test doSomething() without also having your test depend on the correct behaviour of Helper: you can't drop a "pretend" or mock helper object in. This complicates tests in all sorts of ways. It also means that, if you were to come up with an AdvancedHelper in the future, you couldn't make use of it in your code: Helper is hardwired, leading to strong coupling between MyClass and Helper.

The general solution to this and other coupling problems is dependency injection:

Dependency Injection (DI) is a way of abolishing the new keyword from your code (in all except factory and repository objects, which are ntended for the creation of new objects.)

For more background, you can read my other blogposts on DI. For now, this tutorial assumes familiarity with the general concept.

How services are registered in Drupal

Services in Drupal are strongly Symphony, and work in almost the same way. Every module modulename can have a modulename.services.yml file in its top-level folder, and these define which service names map to which classes, and what other options the service might need to be successfully created.

A services can be an object of any class: it doesn't have to implement any interface, or extend any abstract class. For example, create the following file in the d8api module at src/ExampleService.php:

<?php
 
namespace Drupal\d8api;
 
class ExampleService {
}

Then add the following to the d8api.services.yml file in the top-level folder of the module:

services:
  d8api.example:
    class: 'Drupal\d8api\ExampleService'

If the services: key already exists (you might have done these tutorials in a different order!) don't add another one: just add the d8api.example: key and what follows. But make sure you get the indentation right: only services: is unindented.

You could now access the service using:

$exampleService = \Drupal::service('d8api.example');

anywhere in your code. But DI is more subtle than that, as we'll see.

Injecting services

The method for injecting a service into something else depends on what you want the something else is.

Inject one service into another

Injecting services that Drupal knows about, into others Drupal knows about, couldn't be simpler! Alongside your existing ExampleService class file, add the following as src/ExampleServiceInjected.php:

<?php
 
namespace Drupal\d8api;
 
/**
 * An example service, with another service injected.
 */
class ExampleServiceInjected {
 
  /**
   * @var ExampleService
   */
  protected $exampleService;
 
  /**
   * Implements __construct().
   *
   * @param ExampleService $exampleService
   *   The example service, injected.
   */
  public function __construct(ExampleService $exampleService) {
    $this->exampleService = $exampleService;
  }
 
  /**
   * Retrieve the class name of the stored example service.
   *
   * @return string
   *   Name of the class.
   */
  public function retrieveExampleServiceClass() {
    return get_class($this->exampleService);
  }
 
}

This might not look simple, but it's what you'd have to write anyway in order to bring in a helper service: the DI part is yet to come. You can see this class has an object __construct() method, which takes an object of class ExampleService and stores it in a protected object variable. The class name of this injected object can then be returned by another method, as a test that injection has worked.

How do we now perform good DI of ExampleService into our new ExampleServiceInjected? Well, we add another entry into d8api.services.yml to instantiate this new service, but using arguments for its constructor:

services:
  # ... (unchanged) ...
  d8api.example_injected:
    class: 'Drupal\d8api\ExampleServiceInjected'
    arguments: ['@d8api.example']

The @ symbol in the argument indicates that it's not simply the plaintext string "d8api.example", but the actual service with that name should be provided instead. In this way, the ExampleService is instantiated and injected into ExampleServiceInjected, whenever the latter is itself retrieved. No more work is necessary.

Inject services into other code

There are two ways of injecting services into other code:

Whatever interface you declare your object implements, you must then add the class method create() to your class, and implement a __construct() which takes the new DI arguments you want. This differs slightly depending on the interface;

ContainerInjectionInterface is very straightforward:

<?php
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
 
class MyThing implements ContainerInjectionInterface {
 
  protected $exampleService;
 
  public static function create(ContainerInterface $container) {
    return new static($container->get('d8api.example'));
  }
 
  public function __construct(ExampleService $exampleService) {
    // If you're extending some other class, call its constructor.
    parent::__construct();
    $this->exampleService = $exampleService;
  }
 
}

ContainerFactoryPluginInterface is more complex, because the plugin configuration needs to be passed along:

<?php
use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
 
class MyPlugin extends SomePluginClass implements ContainerFactoryPluginInterface {
 
  protected $exampleService;
 
  public static function create(
      ContainerInterface $container,
      array $configuration, $plugin_id, $plugin_definition
    ) {
    return new static(
      $configuration, $plugin_id, $plugin_definition,
      $container->get('d8api.example')
    );
  }
 
  public function __construct(
      array $configuration, $plugin_id, $plugin_definition,
      ExampleService $exampleService
  ) {
    // If you're extending a core plugin class, call its constructor.
    parent::__construct($configuration, $plugin_id, $plugin_definition);
    $this->exampleService = $exampleService;
  }
 
}

Later, when we discuss plugins in more depth, we'll see examples of both of these.

Even though there's a lot of code here (and we've omitted method and variable comments for brevity, which you shouldn't ever do!) it's mostly boilerplate, and only ExampleService, $exampleService, d8api.example etc. are the bits that matter. It's true that in theory, you are calling \Drupal::service(...) here in your own code, through $container->get(...); in practice, the create() code pattern strictly limits the possible side-effects of doing so. As a rule: class methods can safely access $container; but they should never pass $container to the object for its own use later on.

What you should see

If you've edited the d8api.services.yml file, you should rebuild caches. In order to test this code, you should also install Drush. We don't cover either process here.

With Drush installed, open a terminal and change the directory so you're inside the codebase for your Drupal site. You can then type the following:

drush php-eval 'print get_class(\Drupal::service("d8api.example"));'

The response will be:

Drupal\d8api\ExampleService

In this example, Drupal-as-service-container looks up the service, finds and loads the class, instantiates the object and passes it to get_class(). This then prints the class name to the terminal window.

To show that ExampleServiceInjected has been registered correctly as a service, you can run the same code but with the key for the injected service:

drush php-eval 'print get_class(\Drupal::service("d8api.example_injected"));'
# Output:
Drupal\d8api\ExampleServiceInjected

However, you can go one stage further and run the following:

drush php-eval 'print \Drupal::service("d8api.example_injected")->retrieveExampleServiceClass();'
# Output:
Drupal\d8api\ExampleService

This output shows that ExampleService has been successfully instantiated and injected into ExampleServiceInjected, and the latter returned by the container.

If you can see all this then congratulations! you have registered two services with Drupal, and successfully injected one into the other.

Further reading

Add new comment