Tempest in depth

Event bus

Learn how to use Tempest's built-in event bus to dispatch events and decouple different components in your application.

Overview

An event bus is a synchronous communication system that allows different parts of an application to interact while being decoupled from each other.

In Tempest, events can be anything from a scalar value to a simple data class. An event handler can be a closure or a class method, the former needing manual registration and the latter being automatically discovered by the framework.

Defining events

Most events are typically simple data classes that store information relevant to the event. As a best practice, they should not include any logic.

src/AircraftRegistered.php
final readonly class AircraftRegistered
{
    public function __construct(
        public string $registration,
    ) {}
}

When event classes are too much, you may also use scalar values—such as strings or enumerations—to define events. The latter is highly recommended for a better experience.

src/AircraftLifecycle.php
enum AircraftLifecycle
{
    case REGISTERED;
    case RETIRED;
}

Dispatching events

The Tempest\EventBus\EventBus interface implements a dispatch() method, which you may use to dispatch any event. The event bus may be injected as a dependency like any other service:

src/AircraftService.php
use Tempest\EventBus\EventBus;

final readonly class AircraftService
{
    public function __construct(
        public EventBus $eventBus,
    ) {}

    public function register(Aircraft $aircraft): void
    {
        // …

        $this->eventBus->dispatch(new AircraftRegistered(
            registration: $aircraft->icao_code,
        ));
    }
}

Alternatively, Tempest also provides the \Tempest\event() function. It accepts the same arguments as the Tempest\EventBus\EventBus's dispatch() method, but uses service location under the hood to access the event bus.

Handling events

Events are only useful if they are listened for. In Tempest, this is done by calling the listen() method on the EventBus instance, or by using the #[EventHandler] attribute.

Global handlers

Attribute-based event handling is most useful when events should be listened to application-wide. In other words, this is the option you should adopt when the associated event must be acted on every time it is dispatched.

src/AircraftObserver.php
final readonly class AircraftObserver
{
    #[EventHandler]
    public function onAircraftRegistered(AircraftRegistered $event): void
    {
        // …
    }
}

Local handlers

When an event is only meant to be listened for in a specific situation, it is better to register it only when relevant. Such a situation could be, for instance, a console command that needs logging when an event is dispatched.

src/SyncUsersCommand.php
final readonly class SyncUsersCommand
{
    public function __construct(
      private readonly Console $console,
      private readonly UserService $userService,
      private readonly EventBus $eventBus,
    ) {}

    #[ConsoleCommand('users:sync')]
    public function __invoke(AircraftRegistered $event): void
    {
        $this->console->header('Synchronizing users');

        // Listen for the UserSynced to write to the console when it happens
        $this->eventBus->listen(UserSynced::class, function (UserSynced $event) {
            $this->console->keyValue($event->fullName, 'SYNCED');
        });

        // Call external code that dispatches the UserSynced event
        $this->userService->synchronize();
    }
}

Event middleware

When an event is dispatched, it is sent to the event bus, which then forwards it to all registered handlers. Similar to web requests and console commands, the event bus supports middleware.

Event bus middleware can be used for various purposes, such as logging specific events, adding metadata, or performing other pre—or post-processing tasks. These middleware are defined as classes that implement the Tempest\EventBus\EventBusMiddleware interface.

src/EventLoggerMiddleware.php
use Tempest\EventBus\EventBusMiddleware;
use Tempest\EventBus\EventBusMiddlewareCallable;

final readonly class EventLoggerMiddleware implements EventBusMiddleware
{
    public function __construct(
        private Logger $logger,
    ) {}

    public function __invoke(string|object $event, EventBusMiddlewareCallable $next): void
    {
        $next($event);

        if ($event instanceof ShouldBeLogged) {
            $this->logger->info($event->getLogMessage());
        }
    }
}

Middleware priority

All event bus middleware classes get sorted based on their priority. By default, each middleware gets the "normal" priority, but you can override it using the #[Priority] attribute:

use Tempest\Core\Priority;

#[Priority(Priority::HIGH)]
final readonly class EventLoggerMiddleware implements EventBusMiddleware
{ /* … */ }

Note that priority is defined using an integer. You can however use one of the built-in Priority constants: Priority::FRAMEWORK, Priority::HIGHEST, Priority::HIGH, Priority::NORMAL, Priority::LOW, Priority::LOWEST.

Middleware discovery

Global event bus middleware classes are discovered and sorted based on their priority. You can make a middleware class non-global by adding the #[DoNotDiscover] attribute:

use Tempest\Discovery\DoNotDiscover;

#[DoNotDiscover]
final readonly class EventLoggerMiddleware implements EventBusMiddleware
{ /* … */ }

Built-in framework events

Tempest includes a few built-in events that are primarily used internally. While most applications won’t need them, you are free to listen to them if desired.

Most notably, the Tempest\Core\KernelEvent enumeration defines the BOOTED and SHUTDOWN events, which are dispatched when the framework has finished bootstrapping and right before the process is exited, respectively.

Other events include migration-related ones, such as MigrationMigrated, MigrationRolledBack, MigrationFailed and MigrationValidationFailed.

Testing

By extending Tempest\Framework\Testing\IntegrationTest from your test case, you may gain access to the event bus testing utilities using the eventBus property.

These utilities include a way to replace the event bus with a testing implementation, as well as a few assertion methods to ensure that events have been dispatched or are being listened to.

// Prevents events from being handled
$this->eventBus->preventEventHandling();

// Assert that an event has been dispatched
$this->eventBus->assertDispatched(AircraftRegistered::class);

// Assert that an event has been dispatched multiple times
$this->eventBus->assertDispatched(AircraftRegistered::class, count: 2);

// Assert that an event has been dispatched,
// and make custom assertions on the event object
$this->eventBus->assertDispatched(
    event: AircraftRegistered::class,
    callback: fn (AircraftRegistered $event) => $event->registration === 'LX-JFA'
);

// Assert that an event has not been dispatched
$this->eventBus->assertNotDispatched(AircraftRegistered::class);

// Assert that an event has an attached handler
$this->eventBus->assertListeningTo(AircraftRegistered::class);

Preventing event handling

When testing code that dispatches events, you may want to prevent Tempest from handling them. This can be useful when the event’s handlers are tested separately, or when the side-effects of these handlers are not desired for this test case.

To disable event handling, the event bus instance must be replaced with a testing implementation in the container. This may be achieved by calling the preventEventHandling() method on the eventBus property.

tests/MyServiceTest.php
$this->eventBus->preventEventHandling();

Testing a method-based handler

When handlers are registered as methods, instead of dispatching the corresponding event to test the handler logic, you may simply call the method to test it in isolation.

As an example, the following class contains an handler for the AircraftRegistered event:

src/AircraftObserver.php
final readonly class AircraftObserver
{
    #[EventHandler]
    public function onAircraftRegistered(AircraftRegistered $event): void
    {
        // …
    }
}

This handler may be tested by resolving the service class from the container, and calling the method with an instance of the event created for this purpose.

src/AircraftObserverTest.php
// Replace the event bus in the container
$this->eventBus->preventEventHandling();

// Resolve the service class
$observer = $this->container->get(AircraftObserver::class);

// Call the event handler
$observer->onAircraftRegistered(new AircraftRegistered(
    registration: 'LX-JFA',
));

// Assert that a mail has been sent, that the database contains something…
Testing a method-based handler — Tempest