An alternative approach to subscribing to events, or is EventObject really necessary?


The purpose of this article is an attempt to look, from a different point of view, at a description of the event distribution systems.

At the time of this writing, most leading php frameworks implement an event system based on a description of an EventObject .

This has become the standard in php, which has recently been confirmed by the adoption of the psr / event-dispatcher standard.

But it turns out that the description of the event object helps little in developing the listener. For details under cat.

What is the problem

Let's look at the roles and goals of those who use EventObject in development.

  1. A developer (A) who lays down the ability to inject third-party instructions into his process by generating an event.

    The developer describes the EventObject or its signature through an interface.

    When describing an EventObject, the developer’s goal is to give other developers a description of the data object, and in some use cases, to describe the mechanism of interaction with the main thread through this object.

  2. Developer (B) who describes the "listener".

    The developer subscribes to the specified event. In most cases, the listener description should satisfy the callable type.

    At the same time, the developer is not shy in naming classes or methods of the listener. But there is a restriction more by convention that the handler receives an EventObject as an argument.

When adopting the psr / event-dispatcher by the working group, many options for using event distribution systems were analyzed.

The psr standard mentions the following uses:

  1. one-way notice - “I did something if you're interested”
  2. object improvement - “Here is a thing, please change it before I do something with it”
  3. collection - “Give me all your things so that I can do something with this list”
  4. alternative chain - “This is the thing, the first of you to cope with it, do it, then stop”

At the same time, the working group raised many questions to the similar use of event distribution systems, related to the fact that each of the use cases has an “uncertainty” which depends on the implementation of the Dispatcher object.

In the roles described above for developer (B), there is no convenient and well-readable way to understand which of the options for using the event system was chosen by developer (A). The developer will always have to look into the description code not only of the EventObject , but also in the code where this event is generated.

As a result, the signature is a description of the event object, which is designed to facilitate the work of the developer (B). This job does not do well.

Another problem is the not always justified presence of a separate object, which additionally describes the entities already described in the systems.

namespace MyApp\Customer\Events; use MyApp\Customer\CustomerNameInterface; use MyFramevork\Event\SomeEventInterface; class CustomerNameChangedEvent implements SomeEventInterface { /** *     * @return int */ public function getCustomerId(): int; /** *    */ public function getCustomerName(): CustomerNameInterface; } 

In the above example, the CustomerNameInterface object has already been described in the system.

This is reminiscent of the excessive introduction of new concepts. After all, when we need to implement a method, for example, writing to the client’s name change log we do not combine the method arguments into a separate entity, we use the standard description of a method of the form:

  function writeToLogCustomerNameChange( int $customerId, CustomerNameInterface $customerName ) { // ... } 

As a result, we see the following problems:

  1. bad listener code signature
  2. Dispatcher uncertainty
  3. return type uncertainty
  4. introduction of many additional entities like SomeEventObject

Let's look at it from a different perspective

If one of the problems is a poor description of the listener, let's look at the event distribution system not from the description of the event object, but from the description of the listener.

Developer (A) describes how the listener should be described.

  namespace MyApp\Customer\Events; interface CustomerNameChangedListener { public function onCustomerNameChange( int $customerId, CustomerNameInterface $customerName ); } 

Excellent developer (A) was able to convey a description of the listener and the data transmitted to third-party developers.

When writing a listener, the developer (B) types CustomerNameChangedListener in the implements environment and the IDE can add a description of the listener method to his class. Code completion is great.

Let's take a look at the new listener method signature. Even a cursory glance is enough to understand that the version of the event distribution system used is: "one-way notification."

The input data is not transmitted by reference, which means there is no way to modify them in any way so that the changes fall into the main stream. Missing return value; no feedback from the main thread.

What about other use cases? Let's play with the description of the event listener interface.

  namespace MyApp\Customer\Events; interface CustomerNameChangedListener { public function onCustomerNameChange( int $customerId, CustomerNameInterface $customerName ): CustomerNameInterface; } 

There was a requirement for a return value, which means that the listener can (but is not required to) return a different value if it matches the specified interface. Use case: "object improvement".

  namespace MyApp\Customer\Events; interface CustomerNameChangedListener { /** * @return ItemInterface[] */ public function onCustomerNameChange( int $customerId, CustomerNameInterface $customerName ): array; } 

There was a requirement for the return value of a certain type by which it can be understood that this is an element of the collection. Use case: "collection".

  namespace MyApp\Customer\Events; interface CustomerNameChangedListener { public function onCustomerNameChange( int $customerId, CustomerNameInterface $customerName ): VoteInterface; } 

Use case: "alternative chain, voting."

  namespace MyFramework\Events; interface EventControllInterface { public function stopPropagation(); } 

  namespace MyApp\Customer\Events; interface CustomerNameChangedListener { public function onCustomerNameChange( int $customerId, CustomerNameInterface $customerName, EventControllInterface &$eventControll ); } 

Without discussion, it is good or bad to use event stopping.

This option is read quite unambiguously, the main thread provides an opportunity for the listener to stop the event.

So, if we move on to the description of the listener, we get much better readable signatures of listener methods when developing them.

Additionally, we have the opportunity to explicitly point to the main thread to:

  1. acceptability of changes incoming data
  2. return types other than incoming types
  3. explicit transfer of an object with which to stop the propagation of an event

How to implement an event subscription

Options may be different. The general meaning of all the options comes down to the fact that we need to somehow inform the ListenerProvider object (the object that provides the opportunity to subscribe to the event), which event the specific interface belongs to.

You can consider the example of converting the passed object to a callable type. It should be understood that there may be many options for obtaining additional meta-information:

  1. can be passed explicitly, as in the example
  2. can be stored in annotations of listener interfaces
  3. you can use the name of the listener interface as the name of the events

Subscription Implementation Example

  namespace MyFramework\Events; class ListenerProvider { private $handlerAssociation = []; public function addHandlerAssociation( string $handlerInterfaceName, string $handlerMethodName, string $eventName ) { $this->handlerAssociation[$handlerInterfaceName] = [ 'methodName' => $handlerMethodName, 'eventName' => $eventName ]; } public function addHandler(object $handler) { $hasAssociation = false; foreach( $this->handlerAssociation as $handlerInterfaceName => $handlerMetaData ) { if ( $handler interfaceof $handlerInterfaceName ) { $methodName = $handlerMetaData['methodName']; $eventName = $handlerMetaData['eventName']; $this->addListener($eventName, [$handler, $methodName]); $hasAssociation = true; } } if ( !$hasAssociation ) { throw new \Exception('Unknown handler object'); } } } 

We add a configuration method to the subscription object, which for each listener interface describes its metadata, such as the called method and the name of the event.

According to this data, at the time of subscription, we transform the passed $ handler into a callable object with the specified method.

If you notice, the code implies that one $ handler object can implement many event listener interfaces and will be subscribed to each of them. This is an analogue of SubscriberInterface for mass subscription of an object to several events. As you can see, the above implementation does not require a separate mechanism like addSubscriber(SubscriberInterface $subscriber) it turned out to work out of the box.


Alas, the described approach runs counter to the interface accepted as the psr / event-dispatcher standard

Since we do not need to pass any object to Dispatcher. Yes, you can pass the object sugar type:

  class Event { public function __construct(string $eventName, ...$arguments) { // ... } public function getEventName(): string { // ... } public function getArguments(): array { // ... } } 

And use it when generating an event on the psr interface, but it's just plain ugly.

In a good way, the Dispatcher interface would have looked better:

  interface EventDispatcherInterface { public function dispatch(string $eventName, ...$arguments); public function dispatchStopabled(string $eventName, ...$arguments); } 

Why two methods? It is difficult to combine all use cases into a single implementation. It’s better to add your own method for each use case, there will be an unambiguous interpretation of how Dispatcher will process returned values ​​from listeners.

That's all. It would be interesting to discuss with the community whether the approach described has the right to life.


All Articles