Skip to content

Hooks in Symfony

Overview

The Symfony EventDispatcher is an event library used by several PHP applications and frameworks. For example, Symfony SE, Drupal 8, Magento, Laravel, CiviCRM, and many others support EventDispatcher. It provides a common mechanism for dispatching and listening to events. For example:

Civi::dispatcher()->addListener('hook_civicrm_EXAMPLE', $callback, $priority);

Using EventDispatcher is useful if you need more advanced features, such as:

  • Setting the priority of an event-listener
  • Registering an object-oriented event-listener
  • Registering a dynamic, on-the-fly event-listener
  • Registering multiple listeners for the same event
  • Registering for internal/unpublished events

Listeners may registered imperatively (invoking addListener()) or declaratively (tagging classes with EventSubscriberInterface or HookInterface), as in:

class CRM_Example_BAO_Example implements HookInterface {
  function hook_civicrm_EXAMPLE($arg1, $arg2, ...) { ... }
}
Which classes support declarative listeners?

The EventSubscriberInterface and HookInterface are automatically recognized - but only in some contexts. Here are classes and versions that support them:

EventSubscriberInterface HookInterface
Civi\Api4\Event\Subscriber\* Yes (v5.19+) Yes (v5.39+)
BAO classes Yes (v5.39+) Yes (v5.39+)
Headless unit tests Yes (v5.39+) Yes (v4.7+)

To use these interfaces with other classes or versions, you may need to write additional glue code.

For a general introduction or background on EventDispatcher, consult the Symfony documentation.

Examples

HookInterface

The HookInterface allows you define listeners with a naming convention (e.g. hook_*()). These methods will be automatically registered with the eponymous events.

use Civi\Test\HeadlessInterface;
use Civi\Core\HookInterface;
use PHPUnit\Framework\TestCase;

class MyTest extends TestCase implements HeadlessInterface, HookInterface {

  public function hook_civicrm_foobar($arg1, &$arg2, ...) {}

  public function on_civi_api_resolve(\Civi\API\Event\ResolveEvent $event) {}

}

This example demonstrates a few important details:

  • The hook_*() prefix can be used with external/hook-style events. Event-data comes as a list of parameters ($arg1, &$arg2, ...).
  • The on_*() prefix can be used with internal/Symfony-style events. Underscores are converted to dots (civi_api_resolve <=> civi.api.resolve). Event-data comes as an object ($event).
  • The HookInterface is more pithy than EventSubscriberInterface, and it is amenable to mix-in traits, but it does not allow priorities or multiple listeners.

One feature is missing from the MyTest example -- because this feature is only useful with BAOs. The self_*() prefix allows more targeted subscriptions. For example, suppose you defined:

class CRM_Event_BAO_Participant extends CRM_Event_DAO_Participant
  implements HookInterface {

  public function on_civi_api4_validate(\Civi\API\Event\ResolveEvent $event) {}

  public function self_civi_api4_validate(\Civi\API\Event\ResolveEvent $event) {}
}

on_*() and self_*() are very similar, but self_*() is more targeted:

  • on_civi_api4_validate() will run for all cases of civi.api4.validate, even if they have nothing to do with Participant records. In the worst scenario (where all BAOs do all their listeners in on_*() style), you could find that any update to any record requires loading+executing dozens of unrelated BAOs.
  • self_civi_api4_validate() will only run when validating a Participant record. self_*() has an implicit filter.

EventSubscriberInterface

The EventSubscriberInterface allows you define listeners with a special method (getSubscribedEvents()).

use \Symfony\Component\EventDispatcher\EventSubscriberInterface;

class CRM_Example_BAO_Example extends CRM_Example_DAO_Example
      implements EventSubscriberInterface {

  public static function getSubscribedEvents() {
    return [
      'civi.api.prepare' => 'myCiviApiPrepare',
      'hook_civicrm_foo' => ['myFoo', 1000],
      'hook_civicrm_pre::MyEntity' => 'preMyEntity',
      '&hook_civicrm_alterBar' => 'myAlterBar',
    ];
  }

  public function myCiviApiPrepare(\Civi\API\Event\PrepareEvent $event) {}

  public function myFoo(GenericHookEvent $event) {}

  public function preMyEntity(GenericHookEvent $event) {}

  public function myAlterBar(&$barArray, $barMode, ...) {}

}

This example demonstrates a few important details:

  • The class should implement EventSubscriberInterface and define getSubscribedEvents().
  • getSubscribedEvents() may return listeners for many events.
  • Each listener may optionally define a priority (e.g. 1000).
  • Both external events (hook_civicrm_*) and internal events (civi.*) are supported.
  • Event-data comes in object-notation (eg $event) unless you specifically request the external/hook notation (as with & in '&hook_civicrm_alterBar' => 'myAlterBar').
  • The EventSubscriberInterface is more verbose than HookInterface, but it allows more fine-tuning.

Civi::dispatcher()

In this case, we have a CiviCRM extension or Drupal module named example. During the system initialization, we lookup the EventDispatcher, call addListener(), and listen for hook_civicrm_alterContent.

Example 1:

function example_civicrm_config(&$config) {
  if (isset(Civi::$statics[__FUNCTION__])) { return; }
  Civi::$statics[__FUNCTION__] = 1;

  Civi::dispatcher()->addListener('hook_civicrm_alterContent', '_example_say_hello');
}

function _example_say_hello($event) {
  $event->content = 'hello ' . $event->content;
}

Example 2 - referencing a function within a class:

function example_civicrm_config(&$config) {
  Civi::dispatcher()->addListener('hook_civicrm_alterContent', ['CRM_Example_Utils', 'alterContent']);
}

abstract class CRM_Example_Utils {

  function alterContent($event) {
    $event->content = 'hello ' . $event->content;
  }

}

Using the $event object

Hook parameters are passed as an object, $event. For example, hook_civicrm_alterContent has the parameters (&$content, $context, $tplName, &$object). You can access the data as $event->content, $event->context, $event->tplName, and $event->object.

Using hook_civicrm_config

In some environments, hook_civicrm_config runs multiple times. The flag Civi::$statics[__FUNCTION__] prevents duplicate listeners.

Container::findDefinition()

In this case, we have a CiviCRM extension or Drupal module named example. We lookup the defintion of the dispatcher service and amend it.

function example_civicrm_container($container) {
  $container->findDefinition('dispatcher')
    ->addMethodCall('addListener', array('hook_civicrm_alterContent', '_example_say_hello'));
}

function _example_say_hello($event) {
  $event->content = 'hello ' . $event->content;
}

EventSubscriberInterface is supported

Events

CiviCRM broadcasts many different events through the EventDispatcher. These events fall into two categories:

  • External Events/Hooks (v4.7.19+): These have a prefix hook_civicrm_*. They extend the class GenericHookEvent (which, in turn, extends Event). Hooks are simulcast across EventDispatcher as well as CMS-specific event systems.
  • Internal Events (v4.5.0+): These have a prefix civi.*. They extend the class Event. They are only broadcast via EventDispatcher (not CMS-specific event systems).

You can recognize these events by their naming convention. Compare:

// Listen to a hook. Note the prefix, "hook_civicrm_*".
Civi::dispatcher()->addListener('hook_civicrm_alterContent', $callback, $priority);

// Listen to an internal event. Note the prefix, "civi.*".
Civi::dispatcher()->addListener('civi.api.resolve', $callback, $priority);

Methods

The EventDispatcher has several different methods for registering a listener. Our examples have focused on the simplest one, addListener(), but the Symfony documentation describes other methods (addSubscriber()). See also:

Using addListener()

When calling addListener(), you can pass any PHP callable. However, in practice, the safest bet is to pass a string (function-name) or array (class-name, function-name). Other formats may not work with the container-cache.

Priorities

To control the order in which listeners run, one manages the $priority. It will help to have an example configuration:

Listener Name Priority Comment
doSomePrep() +1000 Custom priority. Runs earlier than most.
twiddleThis() 0 Typical priority of many listeners.
delegateToUF() -100 Typical priority of many listeners.
doSomeAlteration() -1000 Custom priority. Runs later than most.

The example supports a few general observations:

  • Highest to lowest: As with any system using Symfony EventDispatcher, execution starts with the highest priority (e.g. +1000) and ends with the lowest priority (e.g. -1000)..
  • Symfony default is 0: When registering new listeners via addListener(), the implied default is $priority=0. We use 0 as the reference-point for "typical". All custom priorities will be higher or lower.
  • External default is -100: All external listeners (e.g. Drupal modules and WordPress plugins) have an effective priority of -100. This default is numerically close to the Symfony default, but it is distinct.

Returning to the example: why would doSomeAlteration() specifically have priority -1000? It's purely a matter convention.

Some Civi subsystems have explicit conventions. For example, the API kernel and Flexmailer both make extensive use of "Internal Events" and internal listeners, and (by convention) the listeners are organized into phases. Each phase has a priority number:

  • In civi.api.* events, the listeners are grouped into three phases: W_EARLY, W_MIDDLE, W_LATE (respectively: +100, 0, -100)
  • In civi.flexmailer.* events, the listeners are grouped into five phases: WEIGHT_START, WEIGHT_PREPARE, WEIGHT_MAIN,WEIGHT_ALTER, WEIGHT_END (respectively: +2000, +1000, 0, -1000, -2000).

In some rare cases where further precision is needed, one might apply a delta. For example, WEIGHT_ALTER+100 would come early in the alteration phase; WEIGHT_ALTER-100 would run late in the alteration phase.

Most Civi events do not have an explicit convention. For want of a convention, you may use this as a baseline:

Priority Description Availability
0 "Normal" or "Default" or "Main" For any listener/software
+1000 "Early" or "Before" or "Pre" or "Prepare" For any listener/software
-1000 "Late" or "After" or "Post" or "Alter" For any listener/software
+2000 Extrema: "First" or "Start" Reserved, subject to the subsystem's design
-2000 Extrema: "Last" or "End" Reserved, subject to the subsystem's design

History

  • CiviCRM v4.5.0: Introduced Symfony EventDispatcher for internal use (within civicrm-core). For example, APIv3 dispatches the events civi.api.resolve and civi.api.authorize while executing an API call.
  • CiviCRM v4.7.0: Introduced hook_civicrm_container.
  • CiviCRM v4.7.0: Integrated the Symfony Container and EventDispatcher.
  • CiviCRM v4.7.19: Integrated hook_civicrm_* with the Symfony EventDispatcher.
  • CiviCRM v4.7.19: Added the Civi::dispatcher() function.
  • CiviCRM v5.39: Extend HookInterface to support on_*() functions
  • CiviCRM v5.39: Extend support for EventSubscriberInterface and HookInterface to both unit-tests and BAOs.
  • CiviCRM v5.52: Minimum supported version of Symfony is now 4.4.
  • CiviCRM v5.57: Switch base-class of CiviCRM events. They no longer extend the deprecated Symfony\Component\EventDispatcher\Event and now extend Symfony\Contracts\EventDispatcher\Event.
  • CiviCRM v5.60: Add support for Symfony 6.

Limitations

  • Boot-critical hooks: hook_civicrm_config, hook_civicrm_container, and hook_civicrm_entityTypes are fired during the bootstrap process -- before the Symfony subsystems are fully online. Consequently, you may not be able to listen for these hooks.
  • Opaque CMS listeners: Most hooks are dispatched through EventDispatcher as well as the traditional hook systems for Drupal modules, Joomla plugins, WordPress plugins, and/or CiviCRM extensions. This is accomplished by daisy-chaining: first, the event is dispatched with EventDispatcher; then, the listener CiviEventDispatcher::delegateToUF() passes the event down to the other systems. If you inspect EventDispatcher, there will be one listener (delegateToUF()) which represents all CMS-based listeners.