Siren

Hooks, Actions & Events

How add_action and do_action map to Siren's typed event system, including listening, broadcasting, and key differences.

Last updated: April 9, 2026

Hooks, Actions & Events

WordPress’s hook system (add_action, do_action, add_filter, apply_filters) is the backbone of plugin development. Siren has an equivalent event system built on typed PHP classes instead of string-named hooks. The concepts map directly: attaching a callback to a hook becomes attaching a handler to an event class, and firing a hook becomes broadcasting an event instance.

Listening to events

In WordPress, you listen to a hook by passing a string name and a callback. In Siren, you listen to a typed event class. There are two ways to do this: the facade approach for quick one-off attachments, and the DI approach for extension classes.

// WordPress: string-named hook, untyped callback
add_action('woocommerce_order_status_completed', function ($order_id) {
    // $order_id is an int, but nothing enforces that
    error_log("Order completed: $order_id");
});
// Siren facade: typed event class, typed callback
use Siren\Events\Core\Facades\Event;
use Siren\Commerce\Events\SaleTriggered;

Event::attach(SaleTriggered::class, function (SaleTriggered $event) {
    // $event is a typed object — IDE autocomplete works here
    $opportunityId = $event->getOpportunityId();
    $details = $event->getTransactionDetails();
});
// Siren DI: handler class + initializer registration

// 1. The handler class
use PHPNomad\Events\Interfaces\CanHandle;
use PHPNomad\Events\Interfaces\Event;

/**
 * @implements CanHandle<\Siren\Commerce\Events\SaleTriggered>
 */
class LogSaleHandler implements CanHandle
{
    public function handle(Event $event): void
    {
        // Your logic here
    }
}

// 2. Register in your Integration or Initializer
use PHPNomad\Events\Interfaces\HasListeners;
use Siren\Commerce\Events\SaleTriggered;

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

The DI approach is preferred for extension development. Handler classes get full constructor injection, so any service the container knows about can be injected into your handler’s constructor. The facade approach is useful for quick prototyping or code outside the DI context.

Broadcasting events

In WordPress, you fire a hook with do_action or apply_filters. In Siren, you broadcast a typed event instance.

// WordPress: fire a string-named hook with arbitrary arguments
do_action('my_plugin_order_processed', $order_id, $customer_email);
// Siren facade: broadcast a typed event instance
use Siren\Events\Core\Facades\Event;

Event::broadcast(new MyCustomEvent($orderId, $customerEmail));
// Siren DI: inject EventStrategy, broadcast from a service
use PHPNomad\Events\Interfaces\EventStrategy;

class OrderProcessingService
{
    protected EventStrategy $events;

    public function __construct(EventStrategy $events)
    {
        $this->events = $events;
    }

    public function processOrder(int $orderId, string $email): void
    {
        // ... processing logic ...
        $this->events->broadcast(new MyCustomEvent($orderId, $email));
    }
}

How does apply_filters work in Siren?

WordPress’s apply_filters passes a value through a chain of callbacks, each modifying it and returning the result. Siren achieves the same thing through mutable events. Instead of passing a return value through a chain, you create an event instance with setters, broadcast it, and then read its state afterward. Listeners mutate the event during broadcast, so by the time broadcast() returns, the event carries the accumulated modifications.

// WordPress: pass a value through a chain of filter callbacks
$price = apply_filters('my_plugin_price', $basePrice, $product);

// Each callback receives the value, modifies it, and returns it
add_filter('my_plugin_price', function ($price, $product) {
    if ($product->isPremium()) {
        return (int) ($price * 0.9);
    }
    return $price;
}, 10, 2);
// Siren facade: create a mutable event, broadcast it, read the result
use Siren\Events\Core\Facades\Event;

$event = new PriceCalculationRequested($basePrice, $product);
Event::broadcast($event);
$price = $event->getPrice(); // Listeners may have modified this

// Attach a listener that mutates the event
Event::attach(PriceCalculationRequested::class, function (PriceCalculationRequested $event) {
    if ($event->getProduct()->isPremium()) {
        $event->setPrice((int) ($event->getPrice() * 0.9));
    }
});

// The event class declares which fields are mutable via setters
class PriceCalculationRequested implements \PHPNomad\Events\Interfaces\Event
{
    protected int $price;
    protected Product $product;

    public function __construct(int $price, Product $product)
    {
        $this->price = $price;
        $this->product = $product;
    }

    public function getPrice(): int { return $this->price; }
    public function setPrice(int $price): void { $this->price = $price; }
    public function getProduct(): Product { return $this->product; }

    public static function getId(): string { return 'price_calculation_requested'; }
}
// Siren DI: broadcast from a service, handle in a listener class

// In your service
class PricingService
{
    protected EventStrategy $events;

    public function __construct(EventStrategy $events)
    {
        $this->events = $events;
    }

    public function calculatePrice(int $basePrice, Product $product): int
    {
        $event = new PriceCalculationRequested($basePrice, $product);
        $this->events->broadcast($event);
        return $event->getPrice();
    }
}

// In your handler class
class ApplyPremiumDiscount implements CanHandle
{
    public function handle(Event $event): void
    {
        if ($event instanceof PriceCalculationRequested && $event->getProduct()->isPremium()) {
            $event->setPrice((int) ($event->getPrice() * 0.9));
        }
    }
}

Siren uses this pattern internally for events like TransactionCreateRequested (where listeners can modify transaction details before they are persisted) and CollaboratorSubmissionReceived (where listeners can modify registration data before the account is created). If an event class has setters, it is designed to be mutated by listeners.

Key differences from WordPress hooks

WordPress hooks are identified by strings ('save_post', 'the_content'). Siren events are identified by PHP class names (SaleTriggered::class). Typos are caught at compile time, and your IDE provides autocomplete for event properties.

Event::attach() accepts an optional ?int $priority parameter, similar to WordPress’s priority argument on add_action. Listeners registered through getListeners() run at default priority in registration order.

Use Event::detach() to remove a previously attached listener, analogous to remove_action in WordPress.

You can register multiple handler classes for the same event in getListeners() by passing an array:

public function getListeners(): array
{
    return [
        Ready::class => [
            RegisterBlocks::class,
            EnqueueAdminAssets::class,
            SetupCollaboratorAdmin::class,
        ],
    ];
}

All three handlers fire when Ready is broadcast.

Where to go next

For the full handler pattern (constructor injection, the CanHandle interface, and common listener patterns), see Listeners & Event Handlers. For how WordPress hooks get translated into Siren domain events (the mechanism that makes integrations work), see Bridging Platform Hooks.

For the full PHPNomad framework documentation on event listeners, see Event Listeners.