Drupal 8 API: events

Pre-requisites

Objects dispatch events to an intermediate; others, subscribing to that event, respond

Drupal's menagerie of third-party and custom modules is a good example of when an unpredictable variety of components are added to a framework, and want to talk to each other while involving the framework core as little as possible.

An example might be when a module saves a particular data structure into the database: it might want to inform other modules that it's about to do so, in case they need to modify it; and it might later want to inform them that it's saved, so they can react. At the same time, not every class in every module should have to be told. Also, the process of event propagation (notifying the next subscriber on the list, once the previous one has finished) needs to be stoppable, so that any one subscriber can step in first and terminate the response of all other subscribers.

Enter the event-driven programming pattern: many subscriber objects tell an intermediary or mediator what kinds of event they want to be notified about; then any notifier object can dispatch some event to the mediator, which then notifies only subscribers to that particular event's type. Other languages like Erlang are well suited to event-driven programming, and good, decoupled Javascript in the browser is very strongly event-driven in practice. But PHP (like Python or Ruby) has no language-core implementation of events, and must instead rely on a library, component, package or whatever the language calls extensions.

Drupal's historic method for implementing event-driven programming has been to use hooks (more on them in a moment), but since Drupal 8's adoption of Symfony components, it has begun to shift its event-driven burden onto Symfony's event system. If you can find an existing event for your code to subscribe to, you can very quickly leverage that to have powerful effects throughout Drupal.

Aside: Drupal hooks: non-OO events, using naming conventions

Although we've generally avoided discussing "legacy" Drupal-isms in these blogposts, then even in Drupal 8 the hooks system is anything but legacy. Even now, the lifecycle of entities uses hooks to communicate.

Pre-dating object orientation, Drupal modules have historically been made of a set of PHP functions, in the global namespace, all beginning with the module's machine name; for example, in the module file d8api.module, hooks look something like:

<?php
 
/**
 * @file
 * D8API module: example only.
 */
 
/**
 * Hook for event "foo"
 */
function d8api_foo($some, $arguments) {
  /* ... */
}
 
/**
 * Hook for event "bar"
 */
function d8api_bar(&$other_arguments) {
  /* ... */
}
 
/* ... (etc.) ... */

The big strengths of hooks are: lightweightness; quick to write; and help encourage coding standards and naming conventions in older, non-OO PHP. The big disadvantage, along with pollution of the global namespace, is that they're largely undiscoverable: if you don't know the hook, you don't know how powerful Drupal can be. Along with robustness and maintenance improvements, this difficulty of discovering unknown hooks is why the move to Symfony events, while not yet complete, is a big improvement for Drupal.

We don't discuss hooks any further here: along with a large amount of existing documentation for previous versions (at least some ten years of it, since Drupal 5.0), there's an open issue to expose existing hooks as a HookEvent in Drupal 8.3.x, which would mean that you could write almost all code using events rather than hooks. But it's important to be aware that hooks are very much still around for the time being.

Subscribing to an event; dispatching a custom one; subscribing to that

We're going to subscribe our custom code to an existing event, dispatched by core request/response handling; this code will then dispatch a custom event, which another part of our custom code will subscribe to and handle in order to log what's happened. To do this cleanly, we write the necessary code back-to-front, to avoid Drupal complaining about any of the classes being missing.

Custom event class

Let's create a custom event class, by extending Symfony's Event. Save the following in the d8api module as src/ExampleEvent.php:

<?php
 
namespace Drupal\d8api;
 
use Symfony\Component\EventDispatcher\Event;
 
/**
 * Custom event.
 */
class ExampleEvent extends Event {
 
  /**
   * @var string
   *   Original message.
   */
  protected $message;
 
  /**
   * Implements __construct().
   *
   * @param string $message
   *   Message provided via query string.
   */
  public function __construct($message) {
    $this->message = $message;
  }
 
  /**
   * @return string $message
   *   Message set by constructor.
   */
  public function getMessage() {
    return $this->message;
  }
 
}

The Symfony class is not abstract, so it needs no further methods. But we're going to use it to notify any subscribers to a particular event type, of a message which we don't want to be changed during event propagation: hence we set this protected $message during the constructor, then only provide a getter to retrieve it.

Event subscriber and dispatcher

An event subscriber class must implement Symfony\Component\EventDispatcher\EventSubscriberInterface, which only consists of one method: getSubscribedEvents(). This should return the following array tree:

[
      $event_type_using_some_class_constant => [
        [$method_name, $optional_weighting],
        [$method_name],
        /* ... (etc.) ... */
      ],
      $another_event_type => [
        [$method_name],
        /* ... (etc.) ... */
      ],
];

The primary array keys should always be class constants, so they can be referred to by name by other people's code: you'll see an example below. For each event type, an array of handlers is provided; each handler is itself an array of either one string value (the name of a method on the object) or a string value followed by a number (the weighting of this handler, relative to all other subscribers' handlers.)

Below we implement a suitable class; save this as src/ExampleEventSubscriber.php in the d8api module:

<?php
 
namespace Drupal\d8api;
 
use Drupal\Core\Logger\LoggerChannel;
use Drupal\Core\Logger\LoggerChannelFactory;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
 
/**
 * Example event subscriber and dispatcher, all in one.
 */
class ExampleEventSubscriber implements EventSubscriberInterface {
 
  // Custom event.
  const D8API = 'd8api.custom_event';
 
  /**
   * @var EventDispatcherInterface
   *   Dispatcher provided by the factory injected below in the constructor.
   */
  protected $eventDispatcher;
 
  /**
   * @var LoggerChannel
   *   Logger provided by the factory injected below in the constructor.
   */
  protected $logger;
 
  /**
   * Implements __construct().
   *
   * Dependency injection defined in services.yml.
   */
  public function __construct(
    EventDispatcherInterface $eventDispatcher,
    LoggerChannelFactory $loggerFactory
  ) {
    $this->eventDispatcher = $eventDispatcher;
    // We inject logger factory, but only save custom channel in state.
    $this->logger = $loggerFactory->get('d8api');
  }
 
  /**
   * {@inheritDoc}
   */
  public static function getSubscribedEvents() {
    return [
      KernelEvents::REQUEST => [['onRequest']],
      ExampleEventSubscriber::D8API => [['onD8api']],
    ];
  }
 
  /**
   * Subscribed event callback: KernelEvents::REQUEST.
   *
   * Look for the query parameter 'd8api' and log what it contains.
   *
   * @param GetResponseEvent $event
   *   An initially empty response event.
   */
  public function onRequest(GetResponseEvent $event) {
    if ($message = $event->getRequest()->get('d8api')) {
      // Set a response object with plain HTML.
      $event->setResponse(new Response(
        "Your message was: " . htmlspecialchars($message)
      ));
      // Dispatch *another* event to log the message.
      $this->eventDispatcher->dispatch(
        ExampleEventSubscriber::D8API,
        new ExampleEvent($message)
      );
    }
  }
 
  /**
   * Subscribed event callback: ExampleEventSubscriber::REQUEST.
   *
   * Log the message on the event.
   *
   * @param ExampleEvent $event
   *   An example event, dispatched by the previous method.
   */
  public function onD8api(ExampleEvent $event) {
    $this->logger->notice(
      "Message received via ExampleEvent: %message",
      ['%message' => $event->getMessage()]
    );
  }
 
}

From top to bottom, this class includes:

Class constant for custom event type
Rather than using a bare string, we define a class constant ExampleEventSubscriber::D8API. If you have a number of custom events, you should create an entirely new class to store them in, for tidiness and separation of concerns: for example, MigrateEvents in core.
Dependency injection
The protected variables and __construct() permit dependency injection, which has been discussed before. This permits the service configuration defined below to provide our custom code with both the event dispatcher service, and a logging factory service for connecting to new or existing logging channels.
Event subscriber interface
The getSubscribedEvents() method subscribes this object to two types of events: an in-Drupal event dispatched by the Symfony kernel when handling HTTP requests; and our custom event defined by our custom code here.
Event handlers
These two handlers are registered in the interface method described previously, to handle the two events. The first handler creates a HTTP response based on the query string of the HTTP request, which prevents further propagation of the GetResponseEvent and returns to the browser; but not before it also dispatches a custom event using an ExampleEvent object; the second handler subscribes to this custom event, and just uses it as an example, to log the message from the HTTP request without actually touching any HTTP objects itself.

Note that we inject a logger factory, but there's unfortunately no factory for Symfony Response objects, meaning we have to have one of the (rare) instances of the new keyword in our code. However, much of the event subscribers in Drupal core follow the same coding pattern, and Response is well tested and (assuming you don't explicitly call any send*() methods) have no side effects.

The class above is intended as a simple example, which is why the same class is subscribing to and dispatching events, but you should be able to see how to apply this to a more complex situation.

Add the subscriber as a service

Finally, to expose your event subscriber to Drupal/Symfony/the dispatcher, append the following to the end of your existing d8api.services.yml:

services:
  # (unchanged)
 
  d8api.event_subscriber:
    class: 'Drupal\d8api\ExampleEventSubscriber'
    arguments: ['@event_dispatcher', '@logger.factory']
    tags:
      - {name: event_subscriber}

These five lines of YAML register our new subscriber class as a service, and tags it as an event_subscriber: all that's required for Symfony to investigate its subscriptions. In addition, it also provides dependency injection of the event dispatcher and the logger.

What you should see

Because you've registered new services, you should clear caches with drush cr. As we've covered this a number of times before, we don't cover it here.

Navigate to the homepage, and you should see no obvious difference:

Homepage, prior to adding any query string

This is because, while the GetResponseEvent is being passed to ExampleEventSubscriber::onRequest(), that code checks for the d8api= query string parameter, and returns silently if none is found.

Now edit the URL in your browser's navigation bar to include the query string ?d8api=hello,%20world. Note that you should encode any whitespace in URLs using its equivalent ASCII code (space=20). You should now see an otherwise empty screen with the text:

Your message was: hello, world

This is because the code is now providing GetResponseEvent with a Symfony HTTP Response, which terminates event propagation and indeed the rest of Drupal's response cycle.

Now, navigate to the logs. You should find that ExampleEventSubscriber::onD8api() has logged a message in the logs, of "type" ("channel") d8api:

Log messages, showing new item

If you navigate through to this log item itself, you can see that the formatting specified in the logging call—use italics for %message—is being respected:

Individual log message, showing $message formatted in italics as per the tokens in the formatting string.

If you can see this, then congratulations! you have learned how to dispatch events, how to subscribe to them, and how to act on them when they're dispatched.

Further reading etc.

Add new comment