Lesson 15 / 21
Events and Listeners
The event, the listener and the dispatcher; separating side effects from the controller; a listener not being wired when the registered name does not match the dispatched name, a queued listener being deferred, and giving the event name as a class name catching the mismatch at registration.
Published Updated
In this lesson
- Separates the event, the listener and the dispatcher and shows how a side effect leaves the controller
- Measures that the listener is not wired when the registered name and the dispatched name differ, and that the response still returns 201
- Compares queued and synchronous listeners and shows when giving the event name as a class name catches the mismatch
Contents
In the Queues and Jobs lesson the controller pushed the thumbnail job onto the queue, but it knew the job’s class by name: new ThumbnailJob(...). If saving a post also has to update a search index, increment a counter and write a log line, the controller grows with every new side effect. This lesson asks: if the controller announces what happened without knowing who will be interested, are the interested parties still called?
An event is the name of something that has happened, together with the data it carries: the PostSaved event carries the id of the post. A listener is a function bound to an event name and called when the event is dispatched. The dispatcher holds the listeners bound to names and, when dispatch() is called, calls the ones that match. The controller only dispatches the event; the index update leaves the controller and goes to the listener. The tie between them is loose: the controller does not know the listener exists, and the listener does not know the controller. This looseness has a cost, and this lesson’s measurement shows it: if the names do not match, nobody reports an error.
AS3. The event name and the name a listener is registered under are matched by string comparison; the match is exact equality, with no prefix or wildcard. If no listener is found while an event is dispatched, no error is thrown: an event with no listeners is legitimate. A queued listener is in the same process, as in the Queues and Jobs lesson; process() runs the waiting ones in the dispatcher. The listener order is the registration order; stopping an event and priorities are not modeled.
The Measurement Core
The same measurement is used in every lesson of the course. ran.php records whether an expected part ran in this request; the file is given verbatim in every lesson, and it has no output block, since it is only saved to disk. Ledger keeps three lists: the parts placed on the request’s path, the parts called within the request, and the parts left outside the request. classify() checks them in order. The decision order is classify() itself: ran, deferred, skipped, not wired. The developer names the part; what is measured are the parts the developer expects to run in this request.
<?php // ran.php — did an expected part run in this request? declare(strict_types=1); final class Ledger { public static array $path = []; // parts placed on the request's path public static array $ran = []; // parts called within the request public static array $deferred = []; // parts left outside the request public static function newRequest(): void { self::$path = self::$ran = self::$deferred = []; } public static function onPath(string $part): void { self::$path[] = $part; } public static function ran(string $part): void { self::$ran[] = $part; } public static function defer(string $part): void { self::$deferred[] = $part; } } function classify(string $part): string { return match (true) { in_array($part, Ledger::$ran, true) => 'ran', in_array($part, Ledger::$deferred, true) => 'deferred', in_array($part, Ledger::$path, true) => 'skipped', default => 'not wired', }; }
This lesson uses all four calls of the core, and for the listener the order matters: when the dispatcher dispatches an event, it places the listeners registered under that name on the path, marks a synchronous one with Ledger::ran() and calls it, and marks a queued one with Ledger::defer() and leaves it on the queue. If no listener is placed on the path, the name is in none of the lists and classify() says not wired.
The Event, the Dispatcher and the Listener
events.php is the whole slice. PostSaved is an immutable event that carries the post id. Index is a reduced form of the search index: only a list of the ids that were added. Dispatcher does three jobs. listen() adds a listener to an event name; dispatch() calls the listeners registered under that name in order, or puts them on the queue; process() runs the ones on the queue and returns how many it ran. listenClass() is a guarded form of listen(): if the class given as the event name does not exist, it throws InvalidArgumentException at registration. The last section shows why this is needed.
PostsController::save() saves the post (in this lesson, saving means the save part runs), dispatches the event and returns 201. The name under which it dispatches is supplied from outside; this makes it possible to mismatch the name on purpose in the next measurement.
<?php // events.php — the event, the dispatcher and the listener declare(strict_types=1); require_once 'ran.php'; final class PostSaved { public function __construct(public readonly int $postId) {} } final class Index { public static array $entries = []; } final class Dispatcher { private array $listeners = []; // event name => [[name, function, queued], ...] private array $queue = []; public function listen(string $event, string $name, callable $callback, bool $queued = false): void { $this->listeners[$event][] = [$name, $callback, $queued]; } public function listenClass(string $class, string $name, callable $callback, bool $queued = false): void { if (!class_exists($class)) { throw new InvalidArgumentException("event class not found: $class"); } $this->listen($class, $name, $callback, $queued); } public function dispatch(string $event, object $payload): void { foreach ($this->listeners[$event] ?? [] as [$name, $callback, $queued]) { Ledger::onPath($name); if ($queued) { Ledger::defer($name); $this->queue[] = [$callback, $payload]; } else { Ledger::ran($name); $callback($payload); } } } public function process(): int { // worker: runs the waiting listeners $count = count($this->queue); while ($entry = array_shift($this->queue)) { [$callback, $payload] = $entry; $callback($payload); } return $count; } } final class PostsController { public function __construct(private Dispatcher $events, private string $eventName) {} public function save(int $postId): array { Ledger::newRequest(); Ledger::onPath('save'); Ledger::ran('save'); $this->events->dispatch($this->eventName, new PostSaved($postId)); return [201, 'saved']; } } function addToIndex(PostSaved $event): void { Index::$entries[] = $event->postId; }
The Listener Is Not Wired
The first measurement sets up two registrations. In both there is an index listener, and the controller dispatches an event named post.saved. In the first the listener is registered under post.created; in the second under post.saved.
<?php // wire.php — the registered name and the dispatched name do not match require_once 'events.php'; function measure(string $registeredName, string $dispatchedName): void { Index::$entries = []; $events = new Dispatcher(); $events->listen($registeredName, 'index', 'addToIndex'); $response = (new PostsController($events, $dispatchedName))->save(7); printf("registered %s, dispatched %s -> %d\n", $registeredName, $dispatchedName, $response[0]); foreach (['save', 'index'] as $part) printf(" %-7s %s\n", $part, classify($part)); echo ' added to index: ', count(Index::$entries), "\n"; } measure('post.created', 'post.saved'); measure('post.saved', 'post.saved');
registered post.created, dispatched post.saved -> 201 save ran index not wired added to index: 0 registered post.saved, dispatched post.saved -> 201 save ran index ran added to index: 1
This lesson’s observation is in the first registration. The response is 201, save ran and index was not wired: the listener did not run, nothing was added to the index, and yet no error appeared. The listener was registered with the dispatcher and was sound as a function; it was just not bound to the dispatched name. classify() looked at the ran list, then the deferred list, then the path list, and found the listener in none of them: because the dispatcher found no listener under that name, it never placed index on the path. This is where skipped and not wired separate. A skipped part was on the path and was not called; a part that is not wired never entered the path.
The second registration is the contrasting measurement. When the names match, the same listener ran and one entry was added to the index. The code is the same; only the equality of two strings changed. This is a direct measurement of the cost of a loose tie: neither the compiler nor the runtime counts a string mismatch as an error.
The Contrast: Queued and Synchronous Listeners
Whether a listener makes the response wait is a separate question. The driver distinction from the Queues and Jobs lesson comes down here to a flag on the listener: queued. The same listener is registered this time with matching names and measured in two forms.
<?php // queued.php — the same listener, synchronous and queued require_once 'events.php'; foreach ([false, true] as $queued) { Index::$entries = []; $events = new Dispatcher(); $events->listen('post.saved', 'index', 'addToIndex', $queued); $response = (new PostsController($events, 'post.saved'))->save(7); echo $queued ? "queued listener" : "synchronous listener", " -> {$response[0]}\n"; echo ' index ', classify('index'), ', added at response time: ', count(Index::$entries), "\n"; echo ' worker ran: ', $events->process(), ', added after: ', count(Index::$entries), "\n"; }
synchronous listener -> 201 index ran, added at response time: 1 worker ran: 0, added after: 1 queued listener -> 201 index deferred, added at response time: 0 worker ran: 1, added after: 1
For the synchronous listener index ran, there is one entry in the index at response time, and the worker has nothing left to do: process() ran zero jobs. For the queued listener index was deferred: the index is empty at response time, process() runs one job, and only then does the entry appear. The numbers are consistent in both directions: in both cases there is one entry in the index at the end, and the difference is in when the entry appears.
The Bounding Measurement
The mismatch stayed silent in the first measurement because the name is a string. If a class name is given as the event name, can this risk be caught at registration? The measurement below tries two registrations with listenClass(): the correctly written PostSaved and PostSavef, which has one letter wrong. It then registers the same wrong name as a string with listen() and dispatches the correct name.
<?php // class.php — the event name as a string or as a class name require_once 'events.php'; foreach (['PostSaved', 'PostSavef'] as $name) { $events = new Dispatcher(); try { $events->listenClass($name, 'index', 'addToIndex'); echo "$name: registration passed\n"; } catch (InvalidArgumentException $error) { echo "$name: registration rejected (", $error->getMessage(), ")\n"; } } Ledger::newRequest(); $events = new Dispatcher(); $events->listen('PostSavef', 'index', 'addToIndex'); // string: accepted silently $events->dispatch('PostSaved', new PostSaved(7)); echo 'registration by string name, index in request: ', classify('index'), "\n";
PostSaved: registration passed PostSavef: registration rejected (event class not found: PostSavef) registration by string name, index in request: not wired
Registering by class name rejected the misspelled name at registration: listenClass() checked whether the class exists and threw an exception because PostSavef does not. This registration happens before the response cycle, while the application is being set up; the developer sees the mistake on the first run, not on the first user request. When the same wrong name was given as a string, the registration was accepted silently, and in the event dispatched under the correct name, index was not wired: the same situation as in the first measurement.
There is a limit. A class name catches a spelling mistake; it does not catch choosing a wrong class that does exist. The protection also comes from the check that listenClass() performs: a class name is also a string, and if no code was written to check it, it is no different from a string. Giving the event name as a class name instead of a string moves the mismatch from run time to registration time, and this gain depends on the check having been built.
Summary
- This lesson’s outcome is not wired: the listener was registered under
post.created, the event was dispatched aspost.saved,indexwas not called, and the response still returned 201. - The dispatcher calls the listeners bound to names; the controller does not know the number or the names of the side effects, but the cost of this is a silent mismatch.
- Its contrast: when the name matches, the listener ran; when marked as queued, it was deferred and the entry appeared only after
process(). - In the bounding measurement, registering the event name as a class name rejected the misspelled name at registration; the string name accepted the same mistake silently.
- A skipped part was on the path and was not called; a part that is not wired never entered the path.
Next Step
When the post was saved, the index was updated, but telling the user is a separate side effect: an email, an in-app alert, a text message? The Notifications lesson answers a single event in more than one way and measures which notification channel the recipient chooses.
To keep your progress and take notes, Log in
My notes
Log in to take notes.