Events & Listeners

Rhapsody includes a simple but powerful event dispatcher system that allows you to decouple various aspects of your application. By dispatching events, you can allow other parts of your application, or "listeners," to react to an action without the original code needing to know about them. This is a great way to handle side effects, like sending a notification email after a user registers.

This approach follows the Observer design pattern and leads to a more modular and maintainable codebase.

The Workflow at a Glance

  1. An action occurs in your application (e.g., a user registers).
  2. Your controller creates and dispatches an Event object containing data about what happened.
  3. The EventDispatcher receives the event and finds all registered Listeners for it.
  4. The dispatcher runs each listener in the order it was registered, passing it the event object: the handle() method of a listener class, or a callable directly.

1. Creating Events and Listeners

The Rhapsody console includes generator commands to quickly create new event and listener classes for your application.

Creating an Event

This command will create a new event class in the app/Events directory. Events are simple data objects that hold information about the event.

php rhapsody make:event UserRegistered

You can then edit the generated UserRegistered.php file to accept data in its constructor, like the user object.

Creating a Listener

This command creates a new listener class in the app/Listeners directory. Listeners contain the logic to handle an event.

php rhapsody make:listener SendWelcomeEmail

You will then implement the logic for sending the email inside the handle() method of the generated SendWelcomeEmail.php file.


2. Registering Listeners

Once you have created your event and listener classes, you need to tell the framework that the listener should be executed when the event is dispatched. This is done in the app/Providers/EventServiceProvider.php file.

The $listen property is an array where the keys are the event classes and the values are arrays of listener classes that should handle them.


// in app/Providers/EventServiceProvider.php
use App\Events\UserRegistered;
use App\Listeners\SendWelcomeEmail;

class EventServiceProvider
{
    protected array $listen = [
        // When the UserRegistered event is dispatched...
        UserRegistered::class => [
            // ...execute the SendWelcomeEmail listener.
            SendWelcomeEmail::class,
        ],
    ];
    // ...
}

3. Dispatching Events

The final step is to dispatch the event from your controller (or any other class). You can do this by injecting the Rhapsody\Core\Events\EventDispatcher into your controller's constructor and calling its dispatch() method.


// in app/Controllers/AuthController.php
use App\Events\UserRegistered;
use Rhapsody\Core\Events\EventDispatcher;

class AuthController extends BaseController
{
    public function __construct(
        protected EventDispatcher $dispatcher,
        // ... other dependencies
    ) {
        // ...
    }
    
    public function register(Request $request): Response
    {
        // ... validation and user creation logic ...

        // After the user is saved, dispatch the event
        $this->dispatcher->dispatch(new UserRegistered($user));
        
        // ... redirect the user ...
    }
}

Now, your controller's only responsibility is to handle the registration. It no longer knows anything about sending emails. You can easily add more listeners (e.g., LogUserRegistration, UpdateUserStats) to the EventServiceProvider in the future without ever having to change your AuthController again.


4. Listener Types and Registering at Runtime

A listener can be either of two things:

Listener How it runs
A class name, e.g. SendWelcomeEmail::classThe class is created by the service container, so its constructor dependencies (a mailer, the entity manager...) are injected automatically. Its handle($event) method is then called. A class with no handle() method but an __invoke() method is invoked instead.
A callable: a closure, or an [$object, 'method'] pairCalled directly with the event.

A plain string is always treated as a class name. To run a function by name, wrap it in a closure.

Registering listeners in code

Besides the EventServiceProvider, you can register a listener at any time with listen(). It accepts a class name or a callable, and anything else is a TypeError:

$dispatcher->listen(UserRegistered::class, SendWelcomeEmail::class);

$dispatcher->listen(UserRegistered::class, function (UserRegistered $event): void {
    // runs after the listeners registered before it
});

One listener or several

In the EventServiceProvider you can give an event either a single listener or a list. Several listeners run in the order they are listed.

protected array $listen = [
    UserRegistered::class => SendWelcomeEmail::class,                       // one listener
    OrderPlaced::class    => [NotifyWarehouse::class, EmailReceipt::class], // several, in order
];

Subscribers

A subscriber is a class that registers several listeners in one place. Give it a subscribe() method that receives the dispatcher, then hand the object to $dispatcher->subscribe(), which calls that method for you. An object without a subscribe() method is ignored.

use Rhapsody\Core\Contracts\EventDispatcherInterface;

class UserEventSubscriber
{
    public function subscribe(EventDispatcherInterface $dispatcher): void
    {
        $dispatcher->listen(UserRegistered::class, SendWelcomeEmail::class);
        $dispatcher->listen(UserLoggedIn::class, LogLogin::class);
    }
}

$dispatcher->subscribe(new UserEventSubscriber());

The ListenerInterface is optional

Rhapsody\Core\Contracts\ListenerInterface describes a listener whose handle(Event $event) accepts any event. The dispatcher does not require it: it only needs a handle() method (or a callable). A listener that handles one specific event, like handle(UserRegistered $event), simply declares that method and does not implement the interface.

Events match by exact class

Listeners are looked up by the exact class of the dispatched event. A listener registered for a parent class or an interface does not run for its subclasses, and there are no wildcards. Register the listener for each concrete event class you want it to hear.


5. How Listeners Behave

  • Order: listeners for an event run in the order they were registered. Their return values are ignored.
  • A failing listener does not break the request. If a listener throws (a mail server is down, say), the dispatcher catches the exception, writes a line to the PHP error log naming the listener and the event, and carries on with the next listener. The code that called dispatch() never sees the exception.
  • Nested events are capped. A listener may dispatch further events, but nesting more than 8 levels deep is skipped and logged. This is only a backstop against listeners that trigger each other in a loop; real chains are one or two levels deep.
  • dispatch() returns the event. You usually ignore it, but for a stoppable event it is how you read the answer a listener supplied.
Don't put must-succeed work in a listener. Because a failing listener is logged and skipped, anything that has to happen (charging a card, saving the order) belongs in your controller or service, not in an event listener. Use listeners for side effects such as emails, notifications and statistics.

6. Event Classes Included with the Framework

Class (Rhapsody\Core\Events) Public properties
UserRegistered$user
PaymentSucceededEvent$transactionId, $amount, $metadata (array)
PaymentFailedEvent$message, $context (array)
RouteNotFound$uri, $method, plus setResponse() / getResponse() (a stoppable event)

7. Stoppable Events (Advanced)

Everything above describes the normal way events work in Rhapsody: dispatching an event is fire-and-forget. The code that calls dispatch() doesn't wait to see what listeners do, and nothing a listener does can change what happens next. This is true for UserRegistered, and it's true for almost every event you'll ever write — the whole point of events is to let listeners react to something without the original code needing to know or care.

A very small number of events in the framework break that rule on purpose: they let a listener supply an answer that the dispatching code actually uses. The framework ships one example of this, Rhapsody\Core\Events\RouteNotFound, which the router dispatches right before it gives up and shows a 404 page — see the Routing documentation for that specific example. This section explains the general mechanism behind it, in case you ever need to build something similar for your own application.

The StoppableEventInterface

An event opts into this behavior by implementing Rhapsody\Core\Events\StoppableEventInterface, which requires a single method:

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

Inside EventDispatcher::dispatch(), after every listener runs, the dispatcher asks two questions: does this event implement StoppableEventInterface, and if so, is isPropagationStopped() now true? If the answer to both is yes, the dispatcher stops calling any remaining listeners and hands the event straight back to whoever called dispatch().

An event that is already stopped when it is dispatched reaches no listeners at all.

Important: this check only ever applies to events that implement the interface. UserRegistered and every other ordinary event you create do not implement it, so they are completely unaffected by any of this — every listener always runs for them, in the order they were registered, exactly as described in the sections above.

Example: RouteNotFound

RouteNotFound is a good concrete example to study, and you can use it directly rather than building your own. It carries the unmatched URI and HTTP method, plus a place for a listener to put a response:

class RouteNotFound extends Event implements StoppableEventInterface
{
    private ?Response $response = null;
    private bool $propagationStopped = false;

    public function __construct(
        public readonly string $uri,
        public readonly string $method,
    ) {}

    // A listener calls this when it has resolved the URI to something real.
    public function setResponse(Response $response): void
    {
        $this->response = $response;
        $this->propagationStopped = true; // setting a response always stops propagation
    }

    public function getResponse(): ?Response
    {
        return $this->response;
    }

    public function isPropagationStopped(): bool
    {
        return $this->propagationStopped;
    }
}

Notice that setResponse() is the only way to mark the event as stopped. You can't accidentally stop propagation without also providing a response, and you can't end up with two different responses from two different listeners — whichever listener calls setResponse() first wins, and no listener registered after it gets a turn.

Writing a Listener for a Stoppable Event

A listener for a stoppable event is written exactly like any other listener (see 1. Creating Events and Listeners above). The only difference is in what it does inside handle(): if it finds a match, it calls setResponse(); if it doesn't, it simply returns and does nothing, leaving the event unstopped so the next listener — or the framework's normal fallback behavior — gets a chance.

namespace App\Listeners;

use Rhapsody\Core\Events\RouteNotFound;
use Rhapsody\Core\Response;

class RedirectLegacyUrls
{
    public function handle(RouteNotFound $event): void
    {
        // Look up $event->uri in your own table of old-slug -> new-slug redirects.
        $newPath = $this->findRedirectFor($event->uri);

        if ($newPath === null) {
            return; // no match — let the next listener try, or fall through to the 404
        }

        $response = new Response();
        $response->setStatusCode(301);
        $response->setHeader('Location', $newPath);

        $event->setResponse($response);
    }

    private function findRedirectFor(string $uri): ?string
    {
        // your own lookup logic here
        return null;
    }
}

You register this listener for RouteNotFound the same way you'd register any other listener — see 2. Registering Listeners above. If you're writing this inside a marketplace module rather than the main application, remember that a module also needs to declare Rhapsody\Core\Events\RouteNotFound in its events.listen.listen permission list in module.json before it's allowed to listen for the event at all — a module that omits this will get a ModulePermissionException instead.