Events

Introduction

An event says that something happened: a user registered, an order was paid. The code that makes it happen dispatches the event and knows nothing of who reacts. Listeners react: send the welcome email, update a counter, tell another service.

Use events to keep one piece of code from knowing every consequence of what it does. When registering a user starts to mean six things, the action dispatches UserRegistered and the six things are six listeners. Do not use an event for a single consequence that the code may as well call directly.

It is the PSR-14 dispatcher, so a library that asks for Psr\EventDispatcher\EventDispatcherInterface is given the same one.

Configuration

Events are registered by default by App::configure(), so they need nothing in app.php; your plugins can add listeners in register().

There are no settings.

Defining an event

An event is any object. Give it the facts a listener needs, as public readonly properties:

// app/Events/UserRegistered.php
namespace App\Events;

final readonly class UserRegistered
{
    public function __construct(public int $userId, public string $email)
    {
    }
}

There is nothing to extend or implement.

Listening

Listeners are added in a plugin's register(). That is the one place, so a reader can find every reaction to an event by searching for its class name.

// app/Setup.php
use App\Events\UserRegistered;
use App\Actions\SendWelcomeEmail;
use Crab\Contracts\Container\Container;
use Crab\Contracts\Events\Dispatcher;

public function register(Container $container): void
{
    $events = $container->get(Dispatcher::class);
    $events->listen(UserRegistered::class, SendWelcomeEmail::class);
    $events->listen(UserRegistered::class, CountSignup::class);
}

[!NOTE] Nothing is discovered. A listener class that is not named in a listen() call never runs, however it is named or wherever it lives.

A listener class

A class with handle() that takes the event. Its constructor takes services, and the container makes it when its first event arrives:

// app/Actions/SendWelcomeEmail.php
namespace App\Actions;

use App\Events\UserRegistered;

final readonly class SendWelcomeEmail
{
    public function __construct(private Mailer $mailer)
    {
    }

    public function handle(UserRegistered $event): void
    {
        $this->mailer->send($event->email, 'Welcome');
    }
}

A closure

For a line or two:

use Crab\Contracts\Log\Logger;

$events->listen(UserRegistered::class, static function (UserRegistered $event) use ($container): void {
    $container->get(Logger::class)->info('User {id} registered', ['id' => $event->userId]);
});

Listening for a family of events

A listener for an interface or a parent class hears every event that implements or extends it:

interface Audited
{
}

final readonly class OrderPaid implements Audited { /* ... */ }
final readonly class OrderRefunded implements Audited { /* ... */ }

$events->listen(Audited::class, WriteAuditLog::class);       // hears both

Dispatching

Ask for the dispatcher and hand it the event:

use App\Events\UserRegistered;
use Crab\Contracts\Database\Database;
use Crab\Contracts\Events\Dispatcher;
use Crab\Http\Response;
use Crab\Validation\Data;
use Crab\Validation\Email;
use Crab\Validation\Fields;

final readonly class RegisterUser
{
    public function __construct(private Database $db, private Dispatcher $events)
    {
    }

    public function handle(#[Fields(['email' => ['string', new Email()], 'name' => 'string'])] Data $input): Response
    {
        $id = $this->db->table('users')->insert(['email' => $input->email, 'name' => $input->name]);

        $this->events->dispatch(new UserRegistered($id, $input->email));

        return Response::created(['id' => $id], "/users/{$id}");
    }
}

dispatch() returns the event, so a listener can leave an answer on an event that has a property for one.

How listeners run

Listeners run at once, one after another, in the order they were added, before dispatch() returns. What a listener throws reaches the code that dispatched, and the listeners after it do not run.

So a failing listener fails the request. That is right when the reaction is part of the work (reserve stock when an order is placed) and wrong when it is a side effect (send an email).

Slow listeners

A listener that sends an email or calls another API makes the client wait for it. Have the listener push a job and return:

use App\Events\UserRegistered;
use Crab\Contracts\Queue\Queue;

final readonly class SendWelcomeEmail
{
    public function __construct(private Queue $queue)
    {
    }

    public function handle(UserRegistered $event): void
    {
        $this->queue->push(SendWelcomeEmailJob::class, userId: $event->userId);
    }
}

The job then runs in a queue worker, is retried when it fails, and the client has its answer already. There are no "queued listeners": a listener that queues is three lines, and what runs where stays visible.

Stopping an event

An event that implements PSR-14's StoppableEventInterface is offered to no further listener once isPropagationStopped() is true. Use it for a question the first listener able to answer should settle:

use Psr\EventDispatcher\StoppableEventInterface;

final class PriceRequested implements StoppableEventInterface
{
    public ?int $price = null;

    public function __construct(public readonly string $sku)
    {
    }

    public function isPropagationStopped(): bool
    {
        return $this->price !== null;
    }
}

Events the framework dispatches

With the default events plugin, the queue and the scheduler tell you what they ran. Listen for these to measure, alert or keep a record of your own:

Event Dispatched Carries
Crab\Contracts\Queue\JobRan After each try of a job message (the job, its queue, data and attempts), seconds, error (what it threw, or null), gaveUp (whether it is now kept as failed)
Crab\Contracts\Schedule\TaskRan After each run of a scheduled task task (its name), seconds, error
use Crab\Contracts\Queue\JobRan;

$events->listen(JobRan::class, static function (JobRan $event) use ($alerts): void {
    if ($event->gaveUp) {
        $alerts->send("{$event->message->job} failed for good: {$event->error?->getMessage()}");
    }
});

They are dispatched in the worker that did the work: a queue worker or the scheduler, not a worker serving HTTP. A listener here that throws is logged and changes nothing for the job or the task. App::PLUGINS puts events before the queue and the schedule.

In tests

The dispatcher works on its own, with no application:

use Crab\Events\Dispatcher;

$events = new Dispatcher();
$heard = [];
$events->listen(UserRegistered::class, static function (UserRegistered $event) use (&$heard): void {
    $heard[] = $event->userId;
});

(new RegisterUser($db, $events))->handle('ada@example.com', 'Ada');

self::assertSame([1], $heard);

To test a listener, call it: (new SendWelcomeEmail($queue))->handle(new UserRegistered(7, 'ada@example.com')).