---
title: 'Events and Listeners'
source: 'https://academia.sh/en/courses/web-application-framework/events-and-listeners'
course: 'The Web Application Framework'
language: en
updated: '2026-10-21T05:03:38+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - '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'
---

# 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.

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
<?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
<?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
<?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
<?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
<?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 as `post.saved`, `index` was 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.
