PSR-14: All about Events

in #php5 years ago (edited)

In the last installment we discussed the overall structure of PSR-14. Today I'd like to dive into the key aspect of any event system: The Events themselves.

Various different event systems structure events in different ways. Some require that it be an object. Others it's any arbitrary value. Others it's any number of arbitrary values, depending on the Event. Some really really want pass-by-reference arrays.

For PSR-14, we chose to standardize on an object and always an object. There were three main reasons for that:

  1. That allows for a single parameter, always, which greatly simplifies the logic of passing the Event information around.
  2. Objects pass by "handle", not by value the way primitives and arrays do, which makes it easier to pass data back to the caller by modifying the object.
  3. Objects are incredibly robust; an Event object can encapsulate any arbitrary data, as well as useful business logic, in a self-documenting way. It also carries with it intrinsic type data that we can leverage far more easily than an anonymous array or simple string.

That third point is important, as PSR-14 has no separate "event name" concept. There's just the Event object itself. In effect, the Event's class is usually its identifier. However it goes much farther than that.

Identifying Events

An Event identified by a name string can only reasonably do simple string matching. Listeners can associate themselves with an event name string and... that's about it. Some systems like Laravel support registering for groups of Events, but that grouping is based on string pattern matching. There is no good standard around that (other than regular expressions, which is way more complexity than is needed here) and defining one in the spec seemed like far too much arbitrary work, especially when the language itself already offered an even better alternative.

In PHP, objects not only have a class, but they can have parent classes and interfaces, too. That information is all handled by the type system intrinsically. That means an Event can be identified by its class, or its parent classes, or the interfaces it supports. That allows "grouping" of Events based on robust type data already baked into the language.

For example, suppose we had a series of lifecycle Events in a data library: DocumentLoaded, DocumentCreated, DocumentUpdated, DocumentToBeSaved, etc. Those are all discrete classes, but they probably all inherit from a common DocumentEvent parent. Additionally, DocumentCreated and DocumentUpdated can both implement an interface for, say, DocumentSavedInterface; whether that interface has methods on it or not is up to the implementer. However, that means a Listener can now register to listen for creation Events, for update Events, or for both by registering for DocumentSavedInterface. Another Listener that only wants to fire for creation Events can register for just DocumentCreated. Yet another Listener, say a logging Listener for development, can listen to all DocumentEvent Events. All using only the metadata already built into PHP itself through its type system.

That also allows for a very simple and convenient way for a Listener to identify what Event types it cares about. While it's not required that a Listener callable have a type declaration it is a really good idea. Not only does it make the code more self documenting, it means that a Provider can tell what Events to pass to a Listener through reflection: If the Event object is type-compatible with the Listener, the Listener gets passed the Event. If not, it doesn't. The PSR-14 Working Group has even published a utility library that includes a trait that can be used to easily extract the type from any callable. (That is, unfortunately, a surprisingly complex task in PHP due to the way callables can be defined.) Feel free to use it in your own implementations.

Minimalist Events and backward compatibility

Events may also be any object; there's not even an empty marker interface requirement. That was a late change to the spec and came down to backward compatibility concerns, mostly. While having a marker interface does have some advantages, it also puts an extra burden on existing implementations (and the zillions of existing event objects in the wild) that need to ensure they add that type to every object. In the end, that wasn't worth it.

Instead, methods are type-declared for object to block non-object events, which wouldn't work anyway. That in turn required a PHP 7.2 dependency. While more version aggressive than most specs tend to be it's a reasonable decision: The update of new PHP versions is far more rapid than it used to be, the next major version of most PHP frameworks are going to have a PHP 7.2 dependency anyway so it won't be a blocker for them adopting PSR-14, and it actually improves backward compatibility.

PHP 7.2 and later allow for limited covariance. That is, you can safely drop a parameter type hint when implementing an interface and it's still considered valid, as it's widening the legal values, not restricting them. So while the Dispatcher interface looks like this:

interface EventDispatcherInterface
{
    public function dispatch(object $event);
}

It is entirely legal in PHP 7.2 and later to do this:

class Dispatcher implements EventDispatcherInterface 
{
    public function dispatch($event) { ... }
}

Or even

class Dispatcher implements EventDispatcherInterface 
{
    public function dispatch($event, $extra = null) { ... }
}

Why is that important? Again, for backward compatibility. Many existing implementations use the method name dispatch() already, as it's a good name here. However, they may have a different method signature with, say, a name string first and then an Event object, or a name string and then a series of primitive values. PHP 7.2 allows the type declarations to be removed, and then the method can inspect the incoming parameters to determine if it's getting a PSR-14 Event object or a legacy call, then translate between them. (Such translation is up to each implementer, as each implementation will be different.)

As an aside, backward compatibility is also the reason why the dispatch() method is required to return the Event, but there's no return type declared. The spec strictly requires that a Dispatcher implementation return the Event object it was passed, but not type hinting it makes it easier for hybrid legacy/PSR-14 implementations to coexist.

Mutating Events

Another important aspect of Event objects is mutability. Most Event systems today in PHP fall into one of two categories:

  1. The Events are one-way with no way to communicate back to the caller, so mutability is not necessary.
  2. The Events are bidirectional and frequently used to pass data back to the caller, so mutability is a requirement.

In practice the latter is far more common than the former, although some argue that the former is more "academically pure" to the concept of an event system.

This is another area where object semantics help us out. Just because an Event is passed by handle, and so the same Event is passed to all Listeners, doesn't mean a given Listener can change it. That's entirely up to the Event object implementation. Consider an Event defined thusly:

class UserLoggedIn
{
   protected $userId;
   protected $timestamp;
  
   public function __constructor(int $userId, \DateTimeImmutable $timestamp)
   {
       $this->userId = $userId;
       $this->timestamp = $timestamp;
   }
  
   public function userId() : int
   {
       return $this->userId;
   }
  
   public function timestamp() : \DateTimeImmutable
   {
       return $this->timestamp;
   }
}

It really doesn't matter that the same object is passed to all Listeners; the object has no public properties and no mutator methods, so it is immutable for all intents and purposes. Whoever defined the Event is establishing an API that is unidirectional, from the caller to Listeners, and doesn't care what Listeners have to say in response.

Conversely, consider:

class SaveUser
{
   protected $user;
  
   public function __construct(User $user)
   {
       $this->user = $user;
   }
  
   public function getUser() : User
   {
       return $this->user;
   }
}

$user = $dispatcher->dispatch(new SaveUser($user))->getUser();

saveToDatabase($user);

Assuming User is a mutable class, Listeners can now easily modify that user object before it is saved to the database.

Should an Event object be mutable, and if so, how? That's up to the implementer. An Event object is an API, and it's up to the implementer to decide what their API is and how they want it to work. PSR-14 doesn't try to define how you design your API: Just how that API is propagated to other libraries in a framework-agnostic way.

Naming things

The astute reader may have noticed that I am not including an Event suffix on most of my Event classes. That's deliberate. While such a pattern is common today, and I have used it in the past, I have more recently started moving away from it hard. It adds very little additional information beyond five more characters to type, which is especially annoying when referencing the class name directly.

Probably the best argument for dropping those suffixes, and the one that convinced me, is that they mostly serve to hide just how bad the class names are to begin with. I doubt I could explain it better than the great Kevlin Heneny in his talk "Seven Ineffective Coding Habits of Many Programmers". The whole thing is worth watching, but the specific part about suffixes is at 33m52s and takes just a few minutes to watch. (He's talking about exceptions, but the concept applies to any common type suffix.)

The PSR-14 spec itself does not give any guidance on Event class naming conventions. That was deliberate. Earlier drafts actually did (my doing), but it was removed in later drafts (also my doing, after seeing Henney's video above). If you want to continue to use *Event naming patterns that is absolutely allowed by the spec.

However, I would encourage you to consider how readable and self-evident the code is if you drop the suffix. Does it make sense? Does it tell you what the purpose of the class is? If not, adding Event to the end doesn't actually help. Fix that name, first. Then... you'll probably find that adding Event to the end doesn't improve understanding at all, so you don't need to do it.

Stoppable Events

Finally, a word on Stoppable Events. Recall there is a third interface in the spec:

interface StoppableEventInterface
{
    public function isPropagationStopped() : bool;
}

There are cases where an Event should not be passed to all Listeners, but just to all Listeners until one of them does "some important thing" and then stop. Javascript events have this concept (although in that case it's based on event "bubbling"), as do many PHP implementations today.

For PSR-14, we chose to abstract it a slight bit further. Typically, implementations today have a stopPropagation() method (or similar) so that a Listener can signal "I did it, we're done, no need to tell anyone else". However, that puts the onus on the Listener to determine what "done" means. The Listener may not be in the best position to do so: The Event is! The Event knows what its internal logic is and when it is "done": Remember, the Event a Listener receives may be a subclass of the Event type it is expecting, so it cannot guarantee that the Event's definition of "done" is the one it was coded against.

Instead, isPropagationStopped() is an API between the Event and the Dispatcher. If an Event implements StoppableEventInterface, then before each Listener it asks "are you done yet?" If the Event says yes, the Dispatcher is done. But it's up to the Event to determine when that is.

The Event can certainly choose to include a stopPropagation()-esque method for Listeners to call if it wants to; that's totally legal. But it can also implement whatever other complex logic it wants, like "stop when at least 3 Foo objects have been added to my collection", something Listeners would never be able to know. We'll see that in action in a later installment.

An Event that does not implement that interface, however, is "unstoppable". (I love that we managed to work that phrase into the design. Unstoppable Events just sounds cool.)

As an example, let's look at the most common Stoppable use case. It should look very familiar to anyone who's used Symfony's "Response" Event. The Event class itself is fairly basic:

class CreateResponse implements StoppableEventInterface
{
   protected $result;

   /** @var RequestInterface */
   protected $request;

   /** @var ResponseInterface */
   protected $response;

   public function __construct($result, RequestInterface $request)
   {
       $this->result = $result;
       $this->request = $request;
   }

   public function result()
   {
       return $this->result;
   }

   public function request() : RequestInterface
   {
       return $this->request;
   }

   public function setResponse(ResponseInterface $response) : void
   {
       $this->response = $response;
   }

   public function isPropagationStopped() : bool
   {
       return !is_null($this->response);
   }
}

It's largely just a basic data carrier for some unspecified $result (say, the return value from a controller) and a PSR-7 RequestInterface object. When it gets set with a PSR-7 ResponseInterface object, it's done. The isPropagationStopped() method will return true, which will cause the Dispatcher that is passing it to Listeners to stop doing so and return it to the caller. A basic Listener for this Event could look like this:

function onNewsArticle(CreateResponse $event) {
   $result = $event->result();
   if ($result instanceof NewsArticle) {
       $page = format_news_article($result);
       $response = make_response($page);
       $event->setResponse($response);
   }
};

That is, if the controller result is a NewsArticle object, it will apply whatever formatting and theming is applicable and turn that into a Response object. No further Listeners will be called. If it's not, then the Listener does nothing and the next Listener in line can take a crack at it.

While this particular example is specifically for creating a PSR-7 Response, the same basic pattern can apply to any "turn some arbitrary thing into this other thing" use case.

PSR-14: The series