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 thanEventSubscriberInterface
, and it is amenable to mix-intrait
s, 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 ofcivi.api4.validate
, even if they have nothing to do withParticipant
records. In the worst scenario (where all BAOs do all their listeners inon_*()
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 aParticipant
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 definegetSubscribedEvents()
. 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 thanHookInterface
, 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 classGenericHookEvent
(which, in turn, extendsEvent
). Hooks are simulcast acrossEventDispatcher
as well as CMS-specific event systems. - Internal Events (v4.5.0+): These have a prefix
civi.*
. They extend the classEvent
. They are only broadcast viaEventDispatcher
(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 viaaddListener()
, the implied default is$priority=0
. We use0
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 eventscivi.api.resolve
andcivi.api.authorize
while executing an API call. - CiviCRM v4.7.0: Introduced
hook_civicrm_container
. - CiviCRM v4.7.0: Integrated the Symfony
Container
andEventDispatcher
. - CiviCRM v4.7.19: Integrated
hook_civicrm_*
with the SymfonyEventDispatcher
. - CiviCRM v4.7.19: Added the
Civi::dispatcher()
function. - CiviCRM v5.39: Extend
HookInterface
to supporton_*()
functions - CiviCRM v5.39: Extend support for
EventSubscriberInterface
andHookInterface
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 extendSymfony\Contracts\EventDispatcher\Event
. - CiviCRM v5.60: Add support for Symfony 6.
Limitations¶
- Boot-critical hooks:
hook_civicrm_config
,hook_civicrm_container
, andhook_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 withEventDispatcher
; then, the listenerCiviEventDispatcher::delegateToUF()
passes the event down to the other systems. If you inspectEventDispatcher
, there will be one listener (delegateToUF()
) which represents all CMS-based listeners.