Drupal 8 Events & Save within a Save
Sep 6, 2018 | Last edit: Dec 31, 2020
Event listeners are a major programming leap and a boon to development in Drupal 8. If you haven't explored them yet, events and events listeners are a gift from the Symfony framework that underpins D8. As their docs say: "Symfony triggers several events related to the kernel while processing the HTTP Request," and third-part bundles also may dispatch events. What this means is a custom module, for example, can now simply hook into the process flow and fire an action when the event takes place. In Drupal 7, we accomplished this with the hook system.
What is so different?
Drupal hooks were somewhat limited, compared to the event system. First of all, they were pre-defined for us. Second, any given module can only respond once to an event. Finally, I always found the function signatures for hooks rather opaque, and forced me to be very reliant on the Drupal documentation which has its own weaknesses.
Weights
One of the mysteries of the hook system in Drupal 7 was understanding the order in which modules and themes would fire. If I have a hook_node_update in X module, and another one in Y module, which one will run first? Sure, we could find out by asking the site, and then manipulate the order, using weights. But these weights are stated upfront in D8, while they were an afterthought in D7. (Actually Symfony uses the term 'priority' not weight. Higher numbers run first.)
$events[KernelEvents::REQUEST][] = [ 'myListenerFunction', 20 ];
Going further, event listeners provide an added layer of control. For each event transition, the listener can be set to fire before the transition or after it. Imagine a complex set of steps for an important transition, like node save: using these tools really helps structure the sequence, easing the development
Default Events
One of the challenges is knowing what default events exist, and when they are called. In Symfony, all events operate at the base level, and allow us to build upon them.
In D8, we have a lot more default events to work with, that can be grouped into common areas:
- config events
- console events
- entity events
- render events
- routing events
More info on default events.
Creating Events
In Drupal 8, we can create our own custom events, or dispatchers. Remember the ole hook function we were just disparaging?? Well we are still reliant on them for many things in D8, such as dispatching custom events. The official docs has an example for a custom user login event. Note here that you're creating a custom event, UserLoginEvent, that is passed to the listener.
/** * Implements hook_user_login(). */ function custom_events_user_login($account) { // Instantiate our event. $event = new UserLoginEvent($account); // Get the event_dispatcher server and dispatch the event. $event_dispatcher = \Drupal::service('event_dispatcher'); $event_dispatcher->dispatch(UserLoginEvent::EVENT_NAME, $event); }
Gotcha
Drupal Commerce 2 has done a great job of integrating this new feature from Symfony. By default, we get access to a handful of events, for example when a customer completes a purchase, and the order transitions to 'placed'.
public static function getSubscribedEvents() { $events = [ 'commerce_order.place.post_transition' => [ 'sendCustomEmail', 10] ], ]; return $events; } public sendCustomEmail(WorkflowTransitionEvent $event) { $order = $event->getEntity(); // Custom function to get relevant data. $fields = $this->getFields($order); // Custom function to format and deliver the email. $this->processCustomEmail($fields); }
Using this pattern, it's so simple to build new listeners and actions for events that it's easy to forget what else is taking place! In my case, we wanted to take several actions when an order is placed:
- update product fields as a method of inventory control
- send confirmation email, for certain products
- send an email requesting more info, for certain orders
- pass order and product info to business reporting tools
Normally this would work great. Until we try to perform the same action on the same entity during the same transition phase. In my case, I discovered I was trying to do $order->save() in two different listeners.
What's wrong with that? The error message I saw was cryptic:
TypeError: Argument 1 passed to _editor_get_file_uuids_by_field() must implement interface Drupal\Core\Entity\EntityInterface, null given, called in /var/platform/web/core/modules/editor/editor.module
Fortunately, others before me had encountered this obstacle and surmounted it.
Turns out that Drupal knows when an entity is out-of-date. And it complains when we try to use that old data.
Looks to me like it's because you're using a stale version of the order; we load the order afresh in the main checkout submit handler, so you should be doing the same thing here.
In the case of events, the listener is told what event fired and what entity is involved. So we're passing the same entity to each of the event listeners, on down the line.
public function customListenerFunction($event) { $entity = $event->getEntity(); ... }
This is helpful because we don't have to look up the entity inside of each event listener. This is NOT helpful when we modify the entity somewhere in the middle, and the entity values become out of date when we reach the final step of the process!
The solution was simple in my case: look up the entity as needed and get the fresh entity values in the event listener.
{ $entity = $event->getEntity(); /** @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityMgr **/ $entityMgr = $this->entityMgr; $order = $entitymgr->getStorage('commerce_order')->load($entity->id()); }
Links:
Checkout fails when Editor module enabled. (FIXED)
The order refresh can cause a save-within-a-save in certain circumstances (FIXED)