Drupal event dispatcher explained by a practical example
Recently, we relaunched a large e-commerce platform for a customer. One of the challenges was to improve the product data flow. The product information management system is among the numerous external services that the website consumes. Unfortunately, due to many past migrations, the system had outdated content, imprecise scope, and a messy data structure. Therefore, we had to build a custom process that imports the data to the structured Drupal Commerce product entities.
Architecting a product data import process
The core idea of the process was to utilize three main steps.
- Prepare Drupal Commerce with store and currency information.
- Read the product data from the product information management system and transfer it to consumable data transfer objects.
- Write the product data to the Drupal Commerce product entities using the data transfer objects.
There were two main advantages to this approach. First, the reader processor is replaceable in the likely scenario that the data source changes. Second, utilizing data transfer objects allows the translation of the product data to a well-defined data structure. Both together imply that the reading and writing processes can act independently.
Data transfer objects
In contrast to classes that define business logic, data transfer objects are intentionally kept simple. The sole purpose is to save information described by basic data types or other data transfer objects. Defined methods are usually only getters, setters, and serializers. A data transfer object for a product could look as follows. We use the promoted read-only properties available as of PHP 8.2 for brevity.
class DTOProduct {
public function __construct(
public readonly int $id,
public readonly string $name,
private array $variations = [],
) {}
public function addVariation(DTOVariation $variation): static {
$this->variations[$variation->id] = $variation;
return $this;
}
public function getVariations(): array {
return $this->variations;
}
}
We see that only the basic data types integer and string are used. The data for the product variations are yet again data transfer objects. This approach makes it simple to pass around the objects between processors even when sleeping or waking (serializing) them, for example, during batch processes.
The problem motivating the usage of events
The product information management system does not track any changes or provide modification timestamps. Therefore, when fetching the product data, there was no way to determine whether there was an update. Of course, one could continually update the product entities with every import. But this approach would be resource intensive.
The idea of using events stems from the motivation that the main process should remain untouched. We introduced two event subscribers - an observer and a recorder - which manipulate data transfer objects from the sidelines after reading and writing.
When the reading process finishes, it dispatches a read-event. The listening observer serializes and hashes given data transfer objects, taking a fingerprint. If the product data changes in the product information management system, the product data transfer object will hold different information; therefore, the fingerprint will differ. In this case, we can inform the main process to update the product entities with an expiry flag on the data transfer object. Then, once the writing process is successful, it dispatches a write-event, and the listening recorder saves the fingerprint.
It is hard to keep the terms subscriber and listener apart. Even in this article, the terminology is not used correctly. Technically, the event subscribers have methods that listen to events. Note that one event subscriber can listen to multiple events.
Sidetrack: How are event subscribers registered in Drupal?
Most likely, most of the readers have registered event subscribers before. A tag in the definition marks a service as such, similar to the following example.
custom_module.recorder:
class: Drupal\custom_module\EventSubscriber\Recorder
arguments: ['@keyvalue']
tags:
- { name: event_subscriber }
However, a collector for these services does not exist in the Drupal core code base. In this case, a compiler pass in the CoreServiceProvider
discovers the event subscribers and adds them as arguments to the event_dispatcher
service definition. Then, the listeners initialize through the constructor of the ContainerAwareEventDispatcher
.
Compiler passes are a concept from the Symfony framework that offers a powerful tool to manipulate the service container. They are worth exploring because the pattern opens up many new possibilities. Interestingly enough, even service collectors are processed through a compiler pass, namely the TaggedHandlersPass
.
Using custom events and event subscribers
First, we need to define an event. The critical point to remember here is that objects are passed by reference. That means that the event can deliver data to potential listeners, and the listeners can also manipulate the provided data.
class WriteEvent extends Event {
public function __construct(protected array $dtos = []) {}
public function getDataTransferObjects(): array {
return $this->dtos;
}
}
Then, we want to subscribe to the event with an event subscriber. We announce the events in the getSubscribedEvents()
method, which the EventSubscriberInterface
requires. Also, we set the method that is called once the event fires.
In this example, the recorder receives the fingerprint of the data transfer object and writes it to the key value storage. Here, one can see a major advantage of using event subscribers. They are services that can use dependency injection. That means we can encapsulate business logic and support testability.
class Recorder implements EventSubscriberInterface {
public function __construct(protected KeyValueFactoryInterface $keyValue) {}
public function record(WriteEvent $event): void {
$collection = $this->keyValue->get('record_collection');
foreach ($event->getDataTransferObjects() as $dto) {
$collection->set($dto->id, $dto->getFingerprint());
}
}
public static function getSubscribedEvents(): array {
return [WriteEvent::class => 'record'];
}
}
Lastly, we only need to dispatch the event with the event dispatcher at an appropriate process point.
$event = new WriteEvent($dtos);
$this->eventDispatcher->dispatch($event);
Granting a look at the dispatch()
method in the ContainerAwareEventDispatcher
reveals that the invocation of the listeners is reasonably straightforward.
Conclusion and further reading
This article taught us about the event dispatcher and how event subscribers are registered through a compiler pass. We considered a practical example where an event-driven architecture helps to isolate processes. Also, we demonstrated the three steps to define custom events, subscribe to them, and dispatch them.
Symfony: The EventDispatcher Component
Registering your PHPUnit test as an event subscriber for testing events
This article was last modified on June 16, 2024.
I am always open for feedback or further questions. Feel free to contact me.
Back to overview