Skip to content

Instantly share code, notes, and snippets.

@bojanz
Last active January 14, 2023 16:59
Show Gist options
  • Star 10 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save bojanz/c5fcf5cef22406096588 to your computer and use it in GitHub Desktop.
Save bojanz/c5fcf5cef22406096588 to your computer and use it in GitHub Desktop.
Extension patterns: events, tagged services, plugins

This documentation is destined for drupal.org. Created first as a gist to make initial comments easier. Rewrites and clarifications welcome. Code samples are simplified for clarity. Perhaps a bit too much?

When talking about extensibility, there are several distinct use cases:

  1. Reacting to an action that has already happened.

The reaction can be anything; outputting a message, sending an email, modifying a related object, etc. Examples:

  • "A node has been saved"
  • "A product has been added to the cart".
  1. Modifying an existing structure before it is used.

Examples:

  • "Alter the country list"

    Add the newly independent Elbonia. Remove Norway because we don't sell to them.

  • "Modify a node before it is saved"

    Add an author if it wasn't specified.

  1. Participating in a process

Multiple modules can participate in a process and affect its outcome. Examples:

  • "Determine if access is allowed for a specific object"

    If one module returns FALSE, access is restricted.

  • "Theme negotiation (determining the active theme)"

    The first module to return a theme, wins. The theme might be determined based on the domain, user preference, etc.

  1. Providing a plugin

A module provides a piece of functionality that can be instantiated with user-provided configuration. Sometimes only once (field widgets, checkout panes), more often multiple times (blocks, rules actions).

Drupal 7

Drupal 7 used different variations of the hook pattern for all four of the use cases.

Providers

Each module describes the hooks it provides for other modules to implement in an api.php file. For example, the block module provides a block.api.php file. This file is for documentation purposes only.

Hooks are invoked using module_invoke_all() and drupal_alter().

/**
 * Discovers all available actions by invoking hook_action_info().
 */
function actions_list($reset = FALSE) {
  $actions = &drupal_static(__FUNCTION__);
  if (!isset($actions) || $reset) {
    $actions = module_invoke_all('action_info');
    drupal_alter('action_info', $actions);
  }

  return (array) $actions;
}

Consumers

Each module can have only one implementation of a specific hook. Hooks are discovered by their name, it's enough to add the hook to the module file and clear the cache for the hook to be recognized.

/**
 * Implements hook_node_insert().
 */
function mymodule_node_insert($node) {
  drupal_set_message('Good job on creating that node!');
  // Now send an email.
}

/**
 * Implements hook_countries_alter().
 */
function mymodule_countries_alter(&$countries) {
  // Elbonia is now independent, so add it to the country list.
  $countries['EB'] = 'Elbonia';
}

/**
 * Implements hook_node_access().
 */
function mymodule_node_access($node, $op, $account) {
  if ($op == 'create' && $account->uid == '33') {
    // User #33 should not be able to create new nodes.
    return FALSE;
  }
}
/**
 * Implements hook_block_info().
 */
function block_block_info() {
  $blocks = array();
  // Declare a block for each block_custom row.
  $result = db_query('SELECT bid, info FROM {block_custom} ORDER BY info');
  foreach ($result as $block) {
    $blocks[$block->bid]['info'] = $block->info;
  }
  return $blocks;
}

Notice that each of these hooks is subtly different. The first one returns nothing. The second one modifies the argument by reference. The third one returns a simple result. The fourth one returns a complex result, possibly referencing several callbacks.

The "info hook pattern", where one hook implementation returns a set of callbacks to be called (one for a settings form, one for the main logic, etc) was the most unique one, easy to get wrong, and the first that D8 developers decided to replace with a more powerful Plugins API.

Changed expectations in Drupal 8

Drupal 8 developers expect code to live in classes, autoloaded, dependency injected and unit tested. Therefore, the new extension patterns need to follow the same approach. Note that Drupal 8 itself still provides many hooks, but no additional ones should be introduced by new code, especially in contrib.

Events

The first two use cases have been replaced by events.

Providers

Each module provides an Events class describing the events it provides:

// Shortened example from core: \Drupal\Core\Config\ConfigEvents.
final class ConfigEvents {
  /**
   * Name of the event fired when saving a configuration object.
   *
   * This event allows modules to perform an action whenever a configuration
   * object is saved. The event listener method receives a
   * \Drupal\Core\Config\ConfigCrudEvent instance.
   */
  const SAVE = 'config.save';
}

// Shortened example from address module: \Drupal\address\Event\AddressEvents.
final class AddressEvents {
  /**
   * Name of the event fired when altering the list of available countries.
   *
   * @see \Drupal\address\Event\AvailableCountriesEvent
   */
  const AVAILABLE_COUNTRIES = 'address.available_countries';
}

Each defined event has its own event class. This is a simple object that holds the main object (or item being altered) and any needed contextual data.

use Symfony\Component\EventDispatcher\Event;

class ConfigCrudEvent extends Event {
  protected $config;

  public function __construct(Config $config) {
    $this->config = $config;
  }

  public function getConfig() {
    return $this->config;
  }
}
use Drupal\Core\Field\FieldDefinitionInterface;
use Symfony\Component\EventDispatcher\Event;

class AvailableCountriesEvent extends Event {
  protected $availableCountries;
  protected $fieldDefinition;

  public function __construct(array $availableCountries, FieldDefinitionInterface $fieldDefinition) {
    $this->availableCountries = $availableCountries;
    $this->fieldDefinition = $fieldDefinition;
  }

  public function getAvailableCountries() {
    return $this->availableCountries;
  }

  public function setAvailableCountries(array $availableCountries) {
    $this->availableCountries = $availableCountries;
    return $this;
  }

  public function getFieldDefinition() {
    return $this->fieldDefinition;
  }
}

An event is fired using the event dispatcher:

class Config extends StorableConfigBase {

  public function save($has_trusted_data = FALSE) {
    // Perform the actual save, then dispatch the event.
    $event = new ConfigCrudEvent($this);
    $this->eventDispatcher->dispatch(ConfigEvents::SAVE, $event);
  }

}
// Somewhere in \Drupal\address\Plugin\Field\FieldType\AddressItem
$event = new AvailableCountriesEvent($availableCountries, $fieldDefinition);
$eventDispatcher->dispatch(AddressEvents::AVAILABLE_COUNTRIES, $event);
$availableCountries = $event->getAvailableCountries();

Consumers

A module responds to events by registering event subscribers. Each event subscriber is a class, and can subscribe to multiple events.

An event subscriber is registered in the module's services.yml file.

services:
  mymodule.dummy_subscriber:
    class: Drupal\mymodule\Event\DummySubscriber
    tags:

      - { name: event_subscriber }

Having to define a services.yml entry is extra boilerplate compared to D7, but it allows us to specify which dependencies should be injected into the subscriber class.

The tags key tells the container what kind of service this is, and optionally allows us to set the service priority. Remember having to implement hook_module_implements_alter() to make a hook run before another hook? No more.

namespace Drupal\mymodule\Event;

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Drupal\address\Event\AddressEvents;
use Drupal\address\Event\AvailableCountriesEvent;
use Drupal\Core\Config\ConfigEvents;
use Drupal\Core\Config\ConfigCrudEvent;

// In real life you would at least limit a subscriber to a single module's events.
class DummySubscriber implements EventSubscriberInterface {

  static function getSubscribedEvents() {
    $events[AddressEvents::AVAILABLE_COUNTRIES][] = array('alterAvailableCountries');
    $events[ConfigEvents::SAVE][] = array('logSave');
    return $events;
  }

  public function alterAvailableCountries(AvailableCountriesEvent $event) {
    $countries = $event->getAvailableCountries();
    $countries['EB'] = 'Elbonia';
    $event->setAvailableCountries($countries);
  }

  public function logSave(ConfigCrudEvent $event) {
    $config = $event->getConfig();
    // Now do something.
  }

}

The event subscriber class can have any number of dependencies injected via the constructor. Notice how the AvailableCountriesEvent needs a setAvailableCountries() method, used by the subscriber to set the altered value back on the event, since $countries are an array and not an object that can be modified by reference.

An event subscriber method can also prevent next event subscribers from being called, via $event->stopPropagation(). Event subscriber classes are only loaded and instantiated when they are about to be invoked, so any other registered event subscribers stay undisturbed.

Drupal 8 solutions: Tagged services

The third use case ("Participating in a process") is handled by tagged services.

In essence, event subscribers are also tagged services. The container collects all services that have a specific tag and passes them to a class that invokes them. For events this is the event dispatcher class. For other tagged services, it is a class defined by the provider.

Providers

The interface all tagged services must implement:

interface ThemeNegotiatorInterface {
  /**
   * Determine the active theme for the request.
   *
   * @param \Drupal\Core\Routing\RouteMatchInterface $route_match
   *   The current route match object.
   *
   * @return string|null
   *   Returns the active theme name, else return NULL.
   */
  public function determineActiveTheme(RouteMatchInterface $route_match);
}

The services.yml entry for the class that receives all tagged services:

# In core this is actually theme.negotiator calling the ThemeNegotiator class
# This example gives it the name it should have, matching other core examples.
theme.chain_negotiator:
  class: Drupal\Core\Theme\ChainThemeNegotiator
  arguments: ['@access_check.theme']
  tags:
    - { name: service_collector, tag: theme_negotiator, call: addNegotiator }

The service_collector tag tells the container to gather all services with the given tag, order them by priority, and pass them to the class using the addNegotiator method.

The class itself:

class ChainThemeNegotiator implements ChainThemeNegotiatorInterface {
  protected $negotiators = [];

  public function addNegotiator(ThemeNegotiatorInterface $negotiator) {
    $this->negotiators[] = $negotiator;
  }

  public function determineActiveTheme(RouteMatchInterface $route_match) {
    foreach ($this->negotiators as $negotiator) {
      // Call each negotiator until one returns a result.
      $theme = $negotiator->determineActiveTheme($route_match);
      if ($theme !== NULL) {
        return $theme;
      }
    }
  }
}

A module can now use the ChainThemeNegotiator service to get the active theme. It is common to name this type of class with a Chain prefix, because it gets a chain of classes and invokes them one by one. Core has ChainBreadcrumbBuilder, ChainEntityResolver, and others using the same pattern.

Note that while the event dispatcher instantiates subscribers one by one, this class gets all instantiated services up front, which can lead to worse performance. There are efforts underway in core to make this process lazy as well.

Consumers

From a consumer perspective tagged services function similarly to event subscribers.

A class is registered via $module.services.yml, given a tag and an optional priority:

# Outside of core the naming pattern would be: $module.$tag.$name
theme.negotiator.default:
  class: Drupal\Core\Theme\DefaultNegotiator
  arguments: ['@config.factory']
  tags:
    - { name: theme_negotiator, priority: -100 }

And the class:

class DefaultNegotiator implements ThemeNegotiatorInterface {
  protected $configFactory;

  public function __construct(ConfigFactoryInterface $config_factory) {
    $this->configFactory = $config_factory;
  }

  public function determineActiveTheme(RouteMatchInterface $route_match) {
    return $this->configFactory->get('system.theme')->get('default');
  }
}

Unlike an event subscriber there is no getSubscribedEvents() method, and the main method (determineActiveTheme in this case) can just return a value instead of needing to set it on the Event method.

Plugins

The Plugins API is a powerful and complex API allowing classes to be instantiated one or more times based on user-provided configuration (usually stored in config entities).

Unlike event subscribers and other tagged services, plugin implementations aren't registered in the services.yml file, instead they have an annotation on top of the class, which is used by core to perform automatic discovery.

Providers

Define an interface for each plugin to implement:

use Drupal\Core\Cache\CacheableDependencyInterface;
use Drupal\Component\Plugin\PluginInspectionInterface;
use Drupal\Component\Plugin\ConfigurablePluginInterface;
use Drupal\Core\Session\AccountInterface;

interface BlockPluginInterface extends ConfigurablePluginInterface, PluginInspectionInterface, CacheableDependencyInterface {
  /**
   * Returns the user-facing block label.
   */
  public function label();

  /**
   * Indicates whether the block should be shown.
   */
  public function access(AccountInterface $account, $return_as_object = FALSE);

  /**
   * Builds and returns the renderable array for this block plugin.
   *
   * @return array
   *   A renderable array representing the content of the block.
   *
   * @see \Drupal\block\BlockViewBuilder
   */
  public function build();
}

Define the annotation used to discover the plugins:

use Drupal\Component\Annotation\Plugin;

/**
 * Defines a Block annotation object.
 *
 * @Annotation
 */
class Block extends Plugin {
  /**
   * The plugin ID.
   */
  public $id;

  /**
   * The administrative label of the block.
   */
  public $admin_label = '';

  /**
   * The category in the admin UI where the block will be listed.
   */
  public $category = '';
}

Define a plugin manager, and pass it the path to the interface and the annotation:

use Drupal\Core\Plugin\DefaultPluginManager;

class BlockManager extends DefaultPluginManager implements BlockManagerInterface, FallbackPluginManagerInterface {

  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
    parent::__construct('Plugin/Block', $namespaces, $module_handler, 'Drupal\Core\Block\BlockPluginInterface', 'Drupal\Core\Block\Annotation\Block');

    $this->alterInfo('block');
    $this->setCacheBackend($cache_backend, 'block_plugins');
  }

}

Register the BlockManager in services.yml:

plugin.manager.block:
  class: Drupal\Core\Block\BlockManager
  parent: default_plugin_manager

Consumers

Define the plugin class under the correct namespace and with the correct annotation:

namespace Drupal\node\Plugin\Block;

use Drupal\Core\Access\AccessResult;
use Drupal\Core\Block\BlockBase;
use Drupal\Core\Cache\Cache;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Session\AccountInterface;

/**
 * Provides a 'Syndicate' block that links to the site's RSS feed.
 *
 * @Block(
 *   id = "node_syndicate_block",
 *   admin_label = @Translation("Syndicate"),
 *   category = @Translation("System")
 * )
 */
class SyndicateBlock extends BlockBase {

  public function defaultConfiguration() {
    return array(
      'block_count' => 10,
    );
  }

  protected function blockAccess(AccountInterface $account) {
    return AccessResult::allowedIfHasPermission($account, 'access content');
  }

  public function build() {
    return array(
      '#theme' => 'feed_icon',
      '#url' => 'rss.xml',
    );
  }

  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
    $form = parent::buildConfigurationForm($form, $form_state);

    // @see ::getCacheMaxAge()
    $form['cache']['#disabled'] = TRUE;
    $form['cache']['max_age']['#value'] = Cache::PERMANENT;
    $form['cache']['#description'] = $this->t('This block is always cached forever, it is not configurable.');

    return $form;
  }

  public function getCacheMaxAge() {
    // The 'Syndicate' block is permanently cacheable, 
    // because its contents can never change.
    return Cache::PERMANENT;
  }

}

Choosing the right solution

Are you allowing modules to react to something that has already happened, or alter a structure? Use events. Do you need to allow your class to have multiple instances created by the user? Use plugins. Otherwise, use tagged services.

@timplunkett
Copy link

I think this is excellent!

One weird part, "The interface all tagged services must implement:"
And then has interface ThemeNegotiatorInterface.
Seems misleading. Tagged services should conform to an interface, but that's up to the class that gathers services of a given tag, and so it's optional.

@michfuer
Copy link

This does a good job of helping to clarify one's thinking of what to leverage in D8 for the problem/task at hand.

My feedback/questions as a recent D8 student.

  1. In the example of defining an Events calls, how does Drupal know it's an Events class?

  2. In the Events:Providers section I was a little confused by the two examples provided. Maybe show one full example, and than another, instead of simultaneously? Or just one example?

  3. "Having to define a services.yml entry is extra boilerplate compared to D7, but it allows us to specify which dependencies should be injected into the subscriber class." I didn't see a dependency being injected in the example, nor a priority parameter?

  4. What's the tag parameter in

tags:
    - { name: service_collector, tag: theme_negotiator, call: addNegotiator }

Is the tag named service_collector or theme_negotiator?

@jpomes-ot
Copy link

Hi Bojan,
How do you use Markdown in your gists ¿?
i can do it in a comments, but not in a gists code 😫

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment