Siren

Event Bindings and Transformers

How getEventBindings() connects platform hooks to Siren domain events, and how transformer callbacks produce those events.

Last updated: April 9, 2026

Event Bindings and Transformers

Event bindings are the bridge between platform hooks and Siren’s domain events. When a sale completes, a refund processes, or a coupon is applied, the platform fires a hook. Your extension’s getEventBindings() method declares which hooks trigger which domain events. Each binding provides a callable that receives the hook’s parameters and returns a domain event instance or null.

Transformer service classes are the standard way to organize those callables. They encapsulate the logic for locating opportunities, preventing duplicates, and constructing events. But they are a convention for testability and separation of concerns, not a requirement. Anything callable works. If a closure gets the job done for a simple binding, use a closure.

For lifecycle context on where getEventBindings() fits in the extension bootstrap process, see The Integration Class. For the domain events themselves, see Commerce Events.

What format do event bindings use?

getEventBindings() returns an associative array keyed by domain event class name. Each value is an array of binding definitions, where each binding maps a platform hook to a callable:

public function getEventBindings(): array
{
    return [
        SaleTriggered::class => [
            ['action' => 'platform_order_created', 'transformer' => $callable],
            ['action' => 'platform_order_completed', 'transformer' => $callable],
        ],
        RefundTriggered::class => [
            ['action' => 'platform_order_refunded', 'transformer' => $callable],
        ],
    ];
}

Each binding definition has two keys:

KeyTypeDescription
actionstringThe platform hook name to listen on (e.g., woocommerce_new_order, edd_insert_payment)
transformercallableA callable that receives the hook’s parameters and returns a domain event or null

When the platform fires the hook, the framework calls your transformer and, if it returns a non-null event, broadcasts that event to Siren’s domain layer. The framework handles all the dispatching. Your extension only produces the event.

For the full PHPNomad specification this builds on, see Event Binding in the PHPNomad docs.

How should the transformer callable work?

The callable receives whatever parameters the platform hook passes and returns a domain event instance or null. The parameter signature must match the hook’s invocation exactly.

Always resolve services from the DI container rather than instantiating them directly. This ensures proper dependency injection and keeps your callables testable:

// Correct: resolve from the container
fn($orderId) => $this->container->get(SaleTriggeredTransformer::class)
    ->getSaleTriggeredEvent($orderId)

// Wrong: direct instantiation bypasses DI
fn($orderId) => (new SaleTriggeredTransformer())
    ->getSaleTriggeredEvent($orderId)

Different platforms pass different parameters to their hooks, so the callable’s signature varies by integration. The transformer adapts those platform-specific parameters into a standardized domain event:

// WooCommerce: hook passes order ID
fn($orderId) => $this->container->get(TransactionTransformerService::class)
    ->getSaleTriggeredEvent($orderId)

// EDD: hook passes order ID and order data array
fn($orderId, $orderData) => $this->container->get(SaleTriggeredTransformer::class)
    ->getSaleTriggeredEvent($orderId, $orderData)

// NorthCommerce: hook passes event name, table, old data, new data, processed flag
fn($event, $table, $old, $new, $processed) => $this->container->get(SaleTriggeredTransformer::class)
    ->getSaleTriggeredEvent($event, $table, $old, $new, $processed)

// Gravity Forms: hook passes entry array and form array
fn($entry, $form) => $this->container->get(SaleTriggeredTransformer::class)
    ->getSaleTriggeredEvent($entry, $form)

Each callable delegates to a method on a service class, but the callable itself is just a thin wrapper that routes platform-specific parameters to the right service method. This separation keeps your getEventBindings() method clean and declarative.

What happens when the callable returns null?

A null return means “no event should fire.” The framework silently skips it. This is not an error condition. It is the normal and expected outcome for most hook invocations.

Common reasons a transformer returns null:

  • The customer was not referred by a collaborator, so there is no affiliate opportunity.
  • The mapping table shows this order has already been processed (duplicate order).
  • The order has no line items or the payment has not been confirmed.
  • The hook fired but the context does not apply (e.g., a NorthCommerce table change event that is not related to orders).

Design your transformers to return null liberally. It is always safer to skip than to produce a malformed event.

Can multiple hooks trigger the same event?

Yes. A single domain event can be bound to multiple platform hooks. This is common when platforms expose different hooks for overlapping scenarios, or when you need to capture the same business outcome from multiple entry points.

WooCommerce binds SaleTriggered to three hooks because a new order can arrive through different paths:

SaleTriggered::class => [
    ['action' => 'woocommerce_new_order', 'transformer' => $saleCallback],
    ['action' => 'woocommerce_order_status_processing', 'transformer' => $saleCallback],
    ['action' => 'woocommerce_order_status_completed', 'transformer' => $saleCallback],
],

RefundTriggered is an even more dramatic example. WooCommerce has seven distinct hooks for the various ways an order can be reversed:

RefundTriggered::class => [
    ['action' => 'woocommerce_order_status_completed_to_failed', 'transformer' => $refundCallback],
    ['action' => 'woocommerce_order_status_completed_to_cancelled', 'transformer' => $refundCallback],
    ['action' => 'woocommerce_order_status_completed_to_refunded', 'transformer' => $refundCallback],
    ['action' => 'woocommerce_order_status_processing_to_failed', 'transformer' => $refundCallback],
    ['action' => 'woocommerce_order_status_processing_to_cancelled', 'transformer' => $refundCallback],
    ['action' => 'woocommerce_order_status_processing_to_refunded', 'transformer' => $refundCallback],
    ['action' => 'wc-completed_to_trash', 'transformer' => $refundCallback],
],

When multiple hooks share a callback, duplicate prevention inside the transformer keeps things safe. The second hook to fire for the same order will find an existing mapping and return null.

Can bindings be conditional?

Yes. Because getEventBindings() returns a plain array, you can add or omit entries based on runtime conditions. This is useful when an event only makes sense if an optional dependency is installed.

WooCommerce only adds RenewalTriggered bindings when WooCommerce Subscriptions is available:

public function getEventBindings(): array
{
    $triggers = [
        SaleTriggered::class => [
            ['action' => 'woocommerce_new_order', 'transformer' => $saleCallback],
        ],
        // ... other bindings
    ];

    if (class_exists(WC_Subscription::class)) {
        $triggers[RenewalTriggered::class] = [
            ['action' => 'woocommerce_subscription_renewal_payment_complete',
             'transformer' => $renewalTransformerCallback],
        ];
    }

    return $triggers;
}

If the class does not exist, the RenewalTriggered binding is simply absent from the returned array and no renewal hooks are registered.

What is the transformer service pattern?

Any callable works as a transformer. A closure, a static method reference, an invokable class. But for real integrations, you want testability, dependency injection access, and a clear separation between hook-routing logic and event-construction logic. The convention is a dedicated transformer service class that the container resolves.

A transformer service has three responsibilities:

  1. Locate the affiliate opportunity. Determine which collaborator (if any) referred this customer.
  2. Check for duplicates. Query the mapping table to ensure this platform entity has not already been processed.
  3. Construct the domain event. Build a transaction details array (via an adapter) and return the event instance.

Here is an annotated walkthrough of a sale transformer:

public function getSaleTriggeredEvent($orderId): ?SaleTriggered
{
    // Normalize input — some hooks pass an order object, others pass an ID
    $orderId = WC()->order_factory->get_order_id($orderId);
    if (!$orderId) return null;

    // 1. Locate the affiliate opportunity
    $opportunity = $this->locateOpportunity($orderId);
    if (!$opportunity) return null;

    // 2. Check for duplicates via mapping table
    if ($this->orderHasTransaction($orderId)) return null;

    // 3. Build transaction details via adapter
    $transactionDetails = $this->detailsAdapter->toArray($orderId);
    if (empty($transactionDetails)) return null;

    // 4. Construct and return the event
    return new SaleTriggered(
        $opportunity->getId(),
        $transactionDetails,
        'wc',           // source extension ID
        $orderId,        // external binding ID
        'wc_order'       // external binding type
    );
}

The opportunity location method uses OpportunityLocatorService with a VisitorOpportunity strategy. This strategy resolves the affiliate opportunity from the visitor’s tracking data (cookie, referral URL, etc.):

protected function locateOpportunity($orderId): ?Opportunity
{
    try {
        return $this->opportunityLocator->locate(
            new VisitorOpportunity($this->visitorIdResolver->resolve())
        );
    } catch (RecordNotFoundException $e) {
        return null;
    }
}

The duplicate check queries the MappingDatastore to see if this external order ID has already been mapped to a Siren transaction:

protected function orderHasTransaction($orderId): bool
{
    try {
        $this->mappings->getByExternalId($orderId, 'wc_order', 'transaction');
        return true;
    } catch (RecordNotFoundException $e) {
        return false;
    }
}

If the mapping exists, the order has already been tracked and the transformer returns null. This is what makes multi-hook bindings safe.

How do transformer variants differ by event type?

Not all transformers follow the full three-step pattern. The shape depends on the event type and what information is available at the time the hook fires. For constructor signatures and event payloads, see Commerce Events. For downstream listener behavior, see the Events Reference.

Sale transformers use the full three-step pattern: locate opportunity, check for duplicates, construct event with transaction details from the adapter. This is the most involved transformer because it creates a new transaction from scratch. See SaleTriggered.

Approval transformers (TransactionCompleted) are simpler. The transaction already exists from a previous SaleTriggered event, so the transformer just looks it up by mapping and wraps it in the event. No opportunity location or adapter is needed:

public function getTransactionCompletedEvent($orderId): ?TransactionCompleted
{
    try {
        $mapping = $this->mappings->getByExternalId($orderId, 'wc_order', 'transaction');
        $transaction = $this->transactions->find($mapping->getLocalId());
        return new TransactionCompleted($transaction);
    } catch (RecordNotFoundException $e) {
        return null;
    }
}

If no mapping exists, the order was never tracked by Siren, so there is nothing to approve. See TransactionCompleted.

Refund transformers follow the same pattern as approval transformers. Look up the existing transaction by mapping, wrap it in the event. If no mapping exists, return null. See RefundTriggered.

Coupon transformers use CurrentUserOpportunity locators instead of VisitorOpportunity because the coupon is applied by the logged-in customer during checkout, not resolved from visitor tracking. Duplicate prevention is not needed because coupon application events are idempotent. See CouponApplied.

Renewal transformers must trace the renewal order back to the original subscription’s initial transaction. They query the mapping table to find the original transaction, then perform a duplicate check on the renewal order ID to avoid double-counting. See RenewalTriggered.

What external type conventions should I follow?

When constructing events that reference external platform entities (like SaleTriggered or RenewalTriggered), you provide an external type string that identifies the kind of entity. The convention is {extension_id}_{entity}:

ExtensionExternal TypeDescription
WooCommerce (wc)wc_orderA WooCommerce order
WooCommerce (wc)wc_productA WooCommerce product
EDD (edd)edd_orderAn EDD payment/order
NorthCommerce (nc)nc_orderA NorthCommerce order
LifterLMS (llms)llms_orderA LifterLMS order

The extension ID prefix matches the value returned by your Integration::getId() method. This keeps external types globally unique across integrations and allows the mapping system to route lookups to the correct extension.

For the full mapping system, including how these external types are stored and queried, see The Mapping System.

What dependencies do transformer services typically need?

Transformer services are resolved from the DI container, so their constructor dependencies are automatically injected. Here are the common ones:

DependencyPurpose
OpportunityLocatorServiceFinds the affiliate opportunity for a visitor or user
VisitorOpportunity or CurrentUserOpportunityStrategy objects that tell the locator how to resolve the opportunity
MappingDatastoreDuplicate prevention and external-to-internal ID lookups
LoggerStrategyLogs exceptions from datastore operations
OrderToTransactionDetailsAdapterConverts platform order data to Siren’s standardized detail format
TransactionDatastoreFetches existing transactions for approval, refund, and renewal transformers

Sale transformers use most of these. Approval and refund transformers typically only need MappingDatastore, TransactionDatastore, and LoggerStrategy. For the adapter’s output format, see Adapters.

Where should transformer files live?

The convention varies slightly between extensions, but the pattern is consistent: transformer classes live in their own subdirectory within the extension, one class per event type.

  • EDD, NorthCommerce, and Gravity Forms use a Transformers/ subdirectory (e.g., SaleTriggeredTransformer.php, RefundTriggeredTransformer.php)
  • WooCommerce and LifterLMS use a Services/ subdirectory (e.g., TransactionTransformerService.php, RefundTransformerService.php)

Either convention works. The important thing is that each transformer is a standalone class with a clear single responsibility: one event type per class.

Complete example: EDD getEventBindings()

Here is the full getEventBindings() implementation from the EDD integration, showing all five event types wired up in a single method. This is a good reference because it covers every commerce event type cleanly:

public function getEventBindings(): array
{
    $saleTransformerCallback = fn($orderId, $orderData) => $this->container->get(
        SaleTriggeredTransformer::class
    )->getSaleTriggeredEvent($orderId, $orderData);

    $transactionCompletedTransformer = fn($orderId, $payment, $customer) => $this->container->get(
        TransactionCompletedTransformer::class
    )->getTransactionCompletedEvent($orderId, $payment, $customer);

    $renewalTransformerCallback = fn($subscriptionId, $expiration, $subscription, $paymentId) => $this->container->get(
        RenewalTriggeredTransformer::class
    )->getRenewalTriggeredEvent($subscriptionId, $expiration, $subscription, $paymentId);

    $couponTransformerCallback = fn($couponCode, $discounts) => $this->container->get(
        CouponAppliedTransformer::class
    )->getCouponAppliedEvent($couponCode, $discounts);

    $refundTransformerCallback = fn($payment) => $this->container->get(
        RefundTriggeredTransformer::class
    )->getRefundTriggeredEvent($payment);

    return [
        SaleTriggered::class => [
            ['action' => 'edd_insert_payment', 'transformer' => $saleTransformerCallback],
            ['action' => 'edd_post_add_manual_order', 'transformer' => $saleTransformerCallback],
        ],
        CouponApplied::class => [
            ['action' => 'edd_cart_discount_set', 'transformer' => $couponTransformerCallback],
        ],
        TransactionCompleted::class => [
            ['action' => 'edd_complete_purchase', 'transformer' => $transactionCompletedTransformer],
        ],
        RefundTriggered::class => [
            ['action' => 'edd_post_refund_payment', 'transformer' => $refundTransformerCallback],
        ],
        RenewalTriggered::class => [
            ['action' => 'edd_subscription_post_renew', 'transformer' => $renewalTransformerCallback],
        ],
    ];
}

Several things to notice:

  • Each transformer callback is defined as a variable before the return statement. This keeps the return array readable.
  • Each callback resolves its service from the container lazily. The service is not instantiated until the hook actually fires.
  • The parameter signatures match EDD’s hook invocations exactly. edd_insert_payment passes ($orderId, $orderData), edd_complete_purchase passes ($orderId, $payment, $customer), and so on.
  • SaleTriggered is bound to two hooks: edd_insert_payment for standard purchases and edd_post_add_manual_order for admin-created orders. The duplicate check inside the transformer prevents double-counting.
  • All five event types are represented: sale creation, payment approval, refund, coupon application, and subscription renewal.

This pattern scales to any platform. Replace the hook names with your platform’s equivalents, adjust the parameter signatures, and implement the corresponding transformer service classes.