Siren

Bridging Platform Hooks

How event bindings connect WordPress plugin hooks to Siren domain events, the mechanism that makes Siren multi-platform.

Last updated: April 9, 2026

Bridging Platform Hooks

When WooCommerce fires woocommerce_new_order, Siren needs to translate that into a SaleTriggered domain event. When Easy Digital Downloads fires edd_complete_purchase, the same thing needs to happen. Event bindings declare these mappings, and transformer callables do the translation. This is the mechanism that makes Siren platform-agnostic. The core never sees WordPress hooks, only typed domain events.

How does the bridge work?

Each integration declares event bindings in getEventBindings(). A binding says: “When this WordPress action fires, call this transformer. If the transformer returns an event, broadcast it.” The transformer receives the hook’s raw arguments and either produces a domain event or returns null to skip.

The key insight is that different platforms fire different hooks with different data shapes, but they all produce the same domain events. Siren’s core processes SaleTriggered identically whether it originated from WooCommerce, Easy Digital Downloads, or LifterLMS.

// WooCommerce fires woocommerce_new_order with an order ID

return [
    SaleTriggered::class => [
        ['action' => 'woocommerce_new_order', 'transformer' => function ($orderId) {
            $order = wc_get_order($orderId);
            if (!$order) return null;

            // Find the affiliate opportunity for this customer
            $opportunity = $this->locateOpportunity($order->get_user_id());
            if (!$opportunity) return null;

            // Check if this order was already processed
            if ($this->alreadyTracked($orderId, 'wc_order')) return null;

            // Convert WooCommerce order data into a Siren event
            $details = $this->buildTransactionDetails($order);
            return new SaleTriggered($opportunity->getId(), $details, 'wc', $orderId, 'wc_order');
        }],
    ],
];
// EDD fires edd_complete_purchase with a payment ID

return [
    SaleTriggered::class => [
        ['action' => 'edd_complete_purchase', 'transformer' => function ($paymentId) {
            $payment = edd_get_payment($paymentId);
            if (!$payment) return null;

            // Find the affiliate opportunity for this customer
            $opportunity = $this->locateOpportunity($payment->user_id);
            if (!$opportunity) return null;

            // Check if this payment was already processed
            if ($this->alreadyTracked($paymentId, 'edd_order')) return null;

            // Convert EDD payment data into a Siren event
            $details = $this->buildTransactionDetails($payment);
            return new SaleTriggered($opportunity->getId(), $details, 'edd', $paymentId, 'edd_order');
        }],
    ],
];
// LifterLMS fires lifterlms_order_complete with an order object

return [
    SaleTriggered::class => [
        ['action' => 'lifterlms_order_complete', 'transformer' => function ($order) {
            $userId = $order->get('user_id');
            if (!$userId) return null;

            // Find the affiliate opportunity for this student
            $opportunity = $this->locateOpportunity($userId);
            if (!$opportunity) return null;

            // Check if this order was already processed
            if ($this->alreadyTracked($order->get('id'), 'llms_order')) return null;

            // Convert LifterLMS order data into a Siren event
            $details = $this->buildTransactionDetails($order);
            return new SaleTriggered($opportunity->getId(), $details, 'llms', $order->get('id'), 'llms_order');
        }],
    ],
];

Three different hooks, three different data shapes, one domain event. Siren’s attribution logic, conversion tracking, and payout calculations work identically regardless of which integration produced the event.

What does the binding format look like?

The full format returned by getEventBindings() maps domain event classes to arrays of hook/transformer pairs:

public function getEventBindings(): array
{
    return [
        SaleTriggered::class => [
            ['action' => 'platform_order_hook', 'transformer' => $saleCallback],
        ],
        TransactionCompleted::class => [
            ['action' => 'platform_payment_confirmed', 'transformer' => $completedCallback],
        ],
        RefundTriggered::class => [
            // Multiple hooks can map to the same event
            ['action' => 'platform_order_refunded', 'transformer' => $refundCallback],
            ['action' => 'platform_order_cancelled', 'transformer' => $refundCallback],
        ],
    ];
}

You can bind multiple WordPress hooks to the same domain event. This is common for refunds, where an order can be reversed through several different status transitions.

When does the transformer return null?

Returning null from a transformer tells the framework to silently skip the event. No error, no log entry. The hook fired but there was nothing for Siren to do. Common reasons to return null:

  • The customer was not referred by an affiliate, so there is no engagement to track.
  • The mapping table shows this order has already been processed. This prevents double-counting when a hook fires more than once.
  • The order has no line items, the payment amount is zero, or required fields are missing.
  • The hook fired for an order type your extension does not handle (e.g., a manual adjustment, not a real sale).

This convention keeps the core clean. The framework never needs to handle “no-op” cases. Transformers handle that filtering at the boundary.

Where to go next

For the full transformer and event binding reference (constructor signatures, the adapter pattern, and all commerce events), see Event Bindings and Transformers and Commerce Events. For how to listen to events after they are broadcast, see Hooks, Actions & Events.

For the full PHPNomad framework documentation on event binding, see Event Binding.