Drupal 8: Services at Your Service

Basic Service

The basic concept of a service is an object instance built through configuration. The most important configuration property for the instance is class. This property tells the service container what class to instantiate.

services: demo.service: class: \BasicClass

This already alleviates the use of new in our code base, so instead of your controllers constantly creating an instance of BasicClass, it can fetch it from the service container.

QuoteCollection Service

Let's take a look at an example service that has a few more configuration options. QuoteCollection is a class that manages an array of quotes and the name of the author.

//demo.services.yml parameters: demo.quotes.aristotle: - "Knowing yourself is the beginning of all wisdom." - "What is a friend? A single soul dwelling in two bodies." - "It is the mark of an educated mind to be able to entertain a thought without accepting it." services: demo.quote.local: class: Drupal\demo\Service\QuoteCollection arguments: - %demo.quotes.aristotle% calls: - ["setAuthor", ["Aristotle"]]

Parameters are static data that can be structured or simple. Parameters can be thought of as variables for the service container. They can be overwritten to control the services configuration, this is helpful for multiple environments.

Arguments is the array of variables passed to the object's constructor, in the example above, the parameter demo.quotes.aristotle is passed to QuoteCollection::__construct function.

Calls is an array of methods to call immediately after instantiation. Each array item under calls has two items within. The first is the name of the method to call, the second is another array, that is the parameters to be passed to the method being called.

Using a Service within a Controller

The service container holds the reference of the service through the service key, in our case demo.quote.local.

<?php class DefaultController implements ControllerInterface { ... public function randomQuoteAction() { $collection = $this->container->get('demo.quote.local'); return $collection->getRandom(); } }

Dependency Injection with Core Services

These all have been fairly stand alone services, which doesn't illustrate how the whole "dependency injection" comes into play. Let's say we want to beef up our quote collection to pull quotes from the database. But we don't want to cloud the responsibility of the QuoteCollection class with database dependencies and logic.

QuoteRepository and @Database

Drupal Core has already defined a slew of services that modules can use. The @database service is one of the most important. This is a core D8 service to interact with the primary database. This is a great example for a service as well, the database service is already configured with the proper connection credentials and can be utilized by controllers and other services alike.

//demo.services.yml services: demo.repo.quote: class: Drupal\demo\Service\QuoteRepository arguments: - @database

The database connection service is "injected" via the constructor of the QuoteRepository class. This gives QuoteRepository the explicit responsibility of fetching the data using the connection's PDO/ORM style methods without the burden of managing a new database connection instance.

QuoteRepository is a good place to stash specific logic around fetching nodes that match a certain criteria. This can be used by a controller or by another service.

<?php namespace Drupal\demo\Service; class QuoteRepository { protected $dbal; public function __construct($dbal) { $this->dbal = $dbal; } public function getQuotes($limit=50) { $select = $this->dbal->select('node'); $select = $select->extend('Drupal\Core\Database\Query\PagerSelectExtender'); $select->condition('type', 'quote', '='); $select->condition('status', 1, '='); $select->fields('nr', array('title')); $select->fields('node', array('nid')); $select->groupBy('nr.nid'); $select->limit($limit); $select->join('node_field_revision', 'nr'); return $select->execute(); } }

Factory Services and Dependency Injection

Now that we have QuoteRepository we can fetch quotes from the database, but we still don't have an elegant way to create a QuoteCollection that is fed from a QuoteRepository. We could create it through controller logic but that requires the use of new, which is a poor solution.

When services require special attention to their construction that is outside the vocabulary of the service container you can opt to use a factory service to create your service.

With our example of QuoteCollection we need a new service definition that is created via QuoteFactory. QuoteFactory will act as our factory service, generate the list of quotes and return the object for the service container.

//demo.services.yml services: demo.quote.factory: class: Drupal\demo\Service\QuoteFactory arguments: - @demo.quote.repo calls: - [setChildClass, [%demo.quotecollection.class%]] demo.quote.database: class: Drupal\demo\Service\QuoteCollection factory_service: demo.quote.factory factory_method: createCollection

This is telling the service container in order to create the service demo.quote.database you need to use the service demo.quote.factory's createCollection method. Within createCollection it fetches the list of quotes, breaks them down into a digestible format for QuoteCollection and returns the object to be processed by the service container.

//lib/Drupal/demo/Service/QuoteFactory.php <?php namespace Drupal\demo\Service; class QuoteFactory { protected $repo; protected $childClass = 'QuoteCollection'; public function __construct($repo) { $this->setRepo($repo); } public function setRepo($repo) { $this->repo = $repo; return $this; } public function setChildClass($className) { $this->childClass = $className; return $this; } public function createCollection($limit = 50) { $quotes = array(); foreach($this->repo->getQuotes($limit) as $quote) { $quotes[] = $quote->title; } return new $this->childClass($quotes); } }

Service Container Tags and EventDispatcher

Tags are a notation to mark a service for a distinct usage, such as to provide Twig Functions or to listen to Kernel Events.

During the construction of the service container, dependency injection classes are given a chance to inspect the container for other services that have been tagged. These services are collected and then processed, usually being set up in such a way that it will be injected into another service, such as an EventDispatcher. Drupal core has already implemented several tags, the one we'll be looking at is called event_subscriber.

The event_subscriber tag marks a service as a listener to core events. It expects the service to implement Symfony\Component\EventDispatcher\EventSubscriberInterface interface, which is really a single method returning an array of events it wants to subscribe to and the method to call when that event is fired.

Drupal Core Events

A few of the events currently implemented in Drupal 8.

  • config.context
  • config.init
  • config.load
  • kernel.request
  • kernel.controller
  • kernel.view
  • kernel.response
  • kernel.terminate

Let's create a service that uses QuoteCollection to implement a welcome message to new users on our site. We can subscribe to the kernel.request event and use our QuoteCollection service to fetch a message and apply it through drupal_set_message.

//demo.services.yml services: demo.greeter: class: Drupal\demo\Service\GreeterListener tags: - { name: event_subscriber } calls: - ["setQuotes", [@demo.quote.database]]

As you can see in the configuration, demo.greeter has a dependency on demo.quote.database. However it can easily be swapped out for demo.quote.local and demo.greeter would be undisturbed by this change, because it has been loosely coupled through the service container.

Let's take a peek at what's under the hood for GreeterListener.

//lib/Drupal/demo/Serivce/GreeterListener.php <?php namespace Drupal\demo\Service; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Symfony\Component\HttpKernel\Event\PostResponseEvent; class GreeterListener implements EventSubscriberInterface { public static function getSubscribedEvents() { return array(KernelEvents::REQUEST => array(array('handleRequest'))); } public function setQuotes($quotes) { $this->quotes = $quotes; } public function handleRequest($event) { if(isset($_SESSION['greeted']) && true === $_SESSION['greeted']) { return true; } $message = $this->quotes->getRandom(); drupal_set_message($message); $_SESSION['greeted'] = true; } }

The GreeterListener service listens to the kernel.request event and binds to its own handleRequest method. Inside that method it retrieves a quote from its QuoteCollection and creates a drupal message through drupal_set_message. It also has some logic with session data to avoid setting a welcome message to every request.

Alias and Private

This hot swap of demo.quote.database for demo.quote.local works for a single dependency, but what if you have controllers and other services all relying on one service, refactoring every dependency can quickly become a chore. Service aliases are like a pointer or a symlink, they're just a reference to the real thing. We can use them in our example to refactor the dependency injection and controller references to use the alias.

//demo.services.yml services: demo.quote: alias: demo.quote.database demo.greeter: #... calls: - ["setQuotes", [@demo.quote]]

And refactoring the controller is just as easy.

<?php class DefaultController implements ControllerInterface { ... public function randomQuoteAction() { $collection = $this->container->get('demo.quote'); return $collection->getRandom(); } }

This leaves us with a single reference for our services to depend on while giving us the flexibility to swap out which service is truly being invoked.

However our underlying services are still public and can be accessed by a controller. We can disable this by setting the service definition property, public to false. This bars it from inter-module dependency and controller logic. You can still reference it within the module's services.yml. The alias selects which service becomes the public service.

//demo.services.yml services: demo.quote.local: class: Drupal\demo\Service\QuoteCollection public: false #...

Parent and Abstract Services

Our two QuoteCollection services now share 2 of the same properties and could use a parent service to manage these common configurations. We don't want this to become a service anyone can use directly but it should serve like an abstract class, a class that can be extended, but not instantiated. With an abstract service, it can only be referenced as a parent for another service, it never gets built.

//demo.services.yml services: demo.quote.base: class: Drupal\demo\Service\QuoteCollection public: false abstract: true

We can now refactor our concrete service to extend from our new "base class" service.

//demo.services.yml services: demo.quote.local: parent: demo.quote.base #...

As a recap on our architecture within the service container. We start with our abstract service demo.quote.base and from there we extend that service definition into two services demo.quote.local and demo.quote.database which are both private. We create an alias to one of these services with a service key of demo.quote. This alias is used in other dependency injection definitions, such as with demo.greeter and is now the only public reference to a quote collection service.

Here is the entirety of the demo.services.yml file. I have refactored the classes into parameters which is a good practice to follow as it makes overriding services easier, particularly with proxy or stub classes in a testing environment.

//demo.services.yml parameters: demo.quotes.aristotle: - "Knowing yourself is the beginning of all wisdom." - "What is a friend? A single soul dwelling in two bodies." demo.quotecollection.class: Drupal\demo\Service\QuoteCollection demo.repo.quote.class: Drupal\demo\Service\QuoteRepository demo.greeter.class: Drupal\demo\Service\GreeterListener demo.quote.factory.class: Drupal\demo\Service\QuoteFactory services: #Interacts with drupal database to fetch quotes demo.quote.repo: class: %demo.repo.quote.class% arguments: - @database #serves as factory service to create database #quote collection service. demo.quote.factory: class: %demo.quote.factory.class% arguments: - @demo.quote.repo calls: - [setChildClass, [%demo.quotecollection.class%]] #public alias for quote collection service demo.quote: alias: demo.quote.database #parent service for quote collection services demo.quote.base: class: %demo.quotecollection.class% public: false abstract: true #Local quote service that only has the quotes from the parameters above. demo.quote.local: parent: demo.quote.base arguments: - %demo.quotes.aristotle% calls: - [setAuthor, ["Aristotle"]] #Database driven quote service, is fed quotes from a node list. demo.quote.database: parent: demo.quote.base factory_service: demo.quote.factory factory_method: createCollection #Event listeners for kernel.request, adds session message #derived from random quote that is fetched from injected quote service demo.quote.greeter: class: %demo.greeter.class% tags: - { name: event_subscriber } calls: - ["setQuotes", [@demo.quote]]

Conclusion

The service container gives more power and versatility to module development. In particular I believe this will benefit abstract modules and the ability for core to provide useful services to module developers. Caching and database connections are a good example of services that module development can now rely on to build services without hard dependencies on global functions or creating their own connections.

External Resources