Siren

Listeners & Event Handlers

Implementing CanHandle to react to Siren domain events with dependency injection.

Last updated: April 6, 2026

Listeners & Event Handlers

Siren is event-driven. Business logic flows through events and handlers, not step-by-step service calls. Listeners are the primary way extensions react to things happening in the system. Examples include a sale completing, the application booting, and a collaborator registering.

This guide covers how to write listeners, register them in your extension, and use dependency injection to access Siren’s services from within your handler logic.

What interface do listeners implement?

Every listener implements PHPNomad\Events\Interfaces\CanHandle (see PHPNomad’s event listener docs). The interface is generic-templated, meaning you declare which event type you handle:

<?php

namespace PHPNomad\Events\Interfaces;

/**
 * @template T of Event
 */
interface CanHandle
{
    /**
     * @param T $event
     * @return void
     */
    public function handle(Event $event): void;
}

Your listener class implements this interface and provides a handle() method. The parameter is always typed as Event (the base interface), but you use instanceof checks or @implements annotations to narrow the type.

How do I write a listener?

Here is a minimal listener that reacts to the Ready event (fired once when the application initializes):

<?php

namespace Siren\WordPress\Extensions\MyExtension\Listeners;

use PHPNomad\Events\Interfaces\CanHandle;
use PHPNomad\Events\Interfaces\Event;

/**
 * @implements CanHandle<\PHPNomad\Core\Events\Ready>
 */
class DoSomethingOnReady implements CanHandle
{
    public function handle(Event $event): void
    {
        // Your logic here. Runs once when the app is ready.
    }
}

The @implements annotation is a docblock convention for IDE support. At runtime, the handle() method receives whatever event was dispatched.

How do I use dependency injection in listeners?

Listeners are resolved through the DI container, so you can type-hint any registered service in your constructor and it will be injected automatically:

<?php

namespace Siren\WordPress\Extensions\MyExtension\Listeners;

use PHPNomad\Events\Interfaces\CanHandle;
use PHPNomad\Events\Interfaces\Event;
use PHPNomad\Logger\Interfaces\LoggerStrategy;
use Siren\Collaborators\Core\Datastores\Collaborator\Interfaces\CollaboratorDatastore;
use Siren\Configs\Core\Datastores\Config\Interfaces\ConfigDatastore;

class MyListener implements CanHandle
{
    protected CollaboratorDatastore $collaborators;
    protected ConfigDatastore $config;
    protected LoggerStrategy $logger;

    public function __construct(
        CollaboratorDatastore $collaborators,
        ConfigDatastore $config,
        LoggerStrategy $logger
    ) {
        $this->collaborators = $collaborators;
        $this->config = $config;
        $this->logger = $logger;
    }

    public function handle(Event $event): void
    {
        // $this->collaborators, $this->config, $this->logger
        // are all available here, fully wired by the container.
    }
}

This is a major advantage of listeners over raw callbacks. You get clean access to datastores, services, and facades without manually resolving anything.

A real example from Siren’s codebase is DisplayAdminNotices, which injects four services:

class DisplayAdminNotices implements CanHandle
{
    protected AdminNoticeProvider $notices;
    protected CanRender $template;
    protected LoggerStrategy $logger;
    protected CanResolvePaths $pathResolver;

    public function __construct(
        AdminNoticeProvider $provider,
        CanRender $template,
        LoggerStrategy $loggerStrategy,
        CanResolvePaths $pathResolver
    ) {
        $this->pathResolver = $pathResolver;
        $this->logger = $loggerStrategy;
        $this->template = $template;
        $this->notices = $provider;
    }

    public function handle(Event $event): void
    {
        // Uses all four injected services to render admin notices
    }
}

How do I register listeners?

Listeners are registered via the getListeners() method on any class that implements HasListeners. In your extension’s Integration class or an Initializer, return a mapping of event class to handler class(es):

use PHPNomad\Core\Events\Ready;
use PHPNomad\Events\Interfaces\HasListeners;

class Integration implements Extension, HasListeners
{
    public function getListeners(): array
    {
        return [
            Ready::class => [
                MyFirstListener::class,
                MySecondListener::class,
            ],
        ];
    }
}

The return type is array<class-string<Event>, class-string<CanHandle>[]|class-string<CanHandle>>. You can map a single handler or an array of handlers to each event.

Single handler shorthand

When you only have one handler for an event, you can skip the array wrapper:

public function getListeners(): array
{
    return [
        FulfillmentGenerationInitiated::class => GenerateFulfillments::class,
        PayoutExportInitiated::class => GeneratePayoutFile::class,
    ];
}

Multiple handlers for the same event

Multiple handlers for the same event is common. Here is a real example from Loader:

public function getListeners(): array
{
    return [
        Ready::class => [
            RegisterBlocks::class,
            SignupFormInitializer::class,
            SetupCollaboratorAdmin::class,
            EnqueueReactAdminAssets::class,
        ],
        UserPermissionsInitialized::class => [
            RegisterRoles::class,
            SetUserPermissions::class,
        ],
    ];
}

All four Ready listeners will fire when the Ready event is broadcast.

How Listeners Are Loaded

Under the hood, the framework’s CanLoadInitializers trait processes getListeners() like this:

if ($initializer instanceof HasListeners) {
    $events = $this->container->get(EventStrategy::class);

    foreach ($initializer->getListeners() as $event => $handlers) {
        foreach (Arr::wrap($handlers) as $handler) {
            $events->attach(
                $event,
                fn(Event $event) => $this->container->get($handler)->handle($event)
            );
        }
    }
}

The handler is not instantiated until the event fires — $this->container->get($handler) runs inside the callback, so constructor DI happens at dispatch time rather than registration time. You never instantiate listeners yourself; the container handles constructor injection automatically. Events are keyed by their fully-qualified class name (Event::getId() returns a static string identifier).

What are common listener patterns?

Pattern 1: The AdminHandler (WordPress Hook Registration)

The most common pattern in WordPress extensions: listen to the Ready event, then register WordPress hooks inside handle(). This gives you access to injected services while deferring WordPress hook registration until the application is fully initialized.

class SetupCollaboratorAdmin implements CanHandle
{
    protected CollaboratorDatastore $collaborators;
    protected ConfigDatastore $config;
    protected RenderService $renderService;

    public function __construct(
        CollaboratorDatastore $collaborators,
        ConfigDatastore $config,
        RenderService $renderService
    ) {
        $this->collaborators = $collaborators;
        $this->config = $config;
        $this->renderService = $renderService;
    }

    public function handle(Event $event): void
    {
        // Register WordPress hooks with full access to injected services
        add_action('wp_dashboard_setup', fn() => $this->addWidgets());
        add_action('admin_menu', fn() => $this->loadSubmenus());
        add_action('admin_enqueue_scripts', fn($hook) => $this->enqueueScripts($hook));
        add_filter('show_admin_bar', fn($current) => $this->maybeShowAdminBar($current));
    }
}

This is why listeners matter for WordPress extensions. Without them, you would need to resolve services from the container manually inside each hook callback. With listeners, constructor DI gives you everything you need.

Pattern 2: Domain Event Reaction

React to something that happened in Siren’s domain layer:

class UpdateEngagementStatusWhenConverted implements CanHandle
{
    protected EngagementDatastore $engagements;

    public function __construct(EngagementDatastore $engagements)
    {
        $this->engagements = $engagements;
    }

    public function handle(Event $event): void
    {
        if ($event instanceof ConversionsAwarded) {
            // Update engagement records when conversions are awarded
        }
    }
}

Pattern 3: Feature Registration

Register capabilities when a registry event fires:

class RegisterCoreEngagementTriggerStrategies implements CanHandle
{
    protected ExtensionRegistryService $extensionRegistryService;

    public function __construct(ExtensionRegistryService $extensionRegistryService)
    {
        $this->extensionRegistryService = $extensionRegistryService;
    }

    public function handle(Event $event): void
    {
        if ($event instanceof EngagementTriggerRegistryInitiated) {
            $event->addStrategy(ReferredSiteVisit::class);
            $event->addStrategy(Manual::class);

            // Conditionally add based on active extension features
            if ($this->extensionRegistryService->extensionsSupportFeatures(Features::Coupons)) {
                $event->addStrategy(BoundCouponUsed::class);
            }
        }
    }
}

This pattern is used extensively for engagement triggers, conversion types, and metric strategies. The registry event carries an addStrategy() method that handlers call to register their contributions.

Pattern 4: Third-Party Plugin Initialization

Initialize add-ons for external plugins at the right time:

class InitializeGravityFormsAddon implements CanHandle
{
    protected SirenGFAddOn $addon;

    public function __construct(SirenGFAddOn $addon)
    {
        $this->addon = $addon;
    }

    public function handle(Event $event): void
    {
        add_action('gform_loaded', function() {
            \GFAddOn::register(SirenGFAddOn::class);
        }, 5);
    }
}

Can multiple listeners handle the same event?

Multiple initializers can each register listeners for the same event. The framework iterates over all initializers and attaches all handlers. For example, Ready typically has listeners from:

  • Siren\WordPress\Core\Strategies\Initializer (capabilities)
  • Siren\WordPress\Integration\Loader (blocks, admin screens, signup forms)
  • Siren\Engagements\Service\Initializer (trigger strategy registration)
  • Your extension (whatever you need)

All of them fire when Ready is broadcast. There is no conflict.

In what order do listeners execute?

Listeners execute in registration order. They fire in the order their initializers are loaded, and within an initializer, in the order they appear in the getListeners() array.

The EventStrategy::attach() method accepts an optional ?int $priority parameter, but the framework’s CanLoadInitializers does not pass a priority when attaching listeners from getListeners(). This means all listeners registered this way run at default priority.

If you need to guarantee ordering between your own listeners, list them in the desired order in your getListeners() array. If you need ordering relative to other initializers, consider the initializer loading sequence (core loads before extensions).

How do listeners differ from event bindings?

Do not confuse getListeners() with getEventBindings(). They serve different purposes:

getListeners() maps domain events to handler classes — it’s for reacting to things that happen inside the application (conversions awarded, app ready, records created). getEventBindings() maps domain events to platform hooks — it connects WordPress actions (like woocommerce_new_order) to Siren events (like SaleTriggered), which is how the platform layer translates external triggers into domain events.

Your extension will typically use getEventBindings() to bridge external hooks into Siren events, and getListeners() to react to those events with business logic.