Siren

Extension Template Reference

File-by-file guide to the siren-extension-template repository.

Last updated: April 6, 2026

Extension Template Reference

A file-by-file guide to the Siren extension template repository at https://github.com/Novatorius/siren-extension-template. Each section explains what the file does, what to customize, and when you might remove it.

Directory Structure

siren-extension-template/
  plugin.php                                  # WordPress bootstrap
  composer.json                               # Autoloading and dependencies
  lib/
    Integration.php                           # Main extension class
    Transformers/
      SaleTriggeredTransformer.php            # Hook-to-event bridge
    Adapters/
      OrderToTransactionDetailsAdapter.php    # Data format conversion
    Handlers/
      AdminHandler.php                        # Event listener with DI

plugin.php (WordPress Bootstrap)

This is the entry point. WordPress reads its header comment for plugin metadata, and the body registers the extension with Siren.

Anatomy

<?php
/*
 * Plugin Name: Siren Affiliates - Your Extension Integration
 * Description: Integrates Your Extension with Siren Affiliates.
 * Author: Novatorius, LLC
 * Author URI: https://sirenaffiliates.com
 * Version: 1.0.0
 * Requires PHP: 8.1
 */

use Siren\Extensions\Core\Facades\Extensions;
use YourExtension\Integration;

const SIREN_YOUR_EXTENSION_ROOT = __FILE__;

if (! function_exists('add_action')) {
    return;
}

add_action('siren_ready', function () {
    Extensions::add(Integration::getId(), fn() => new Integration());
}, 0);

What each section does

The plugin header is standard WordPress metadata — update the name, description, author, and URI. The Requires PHP: 8.1 constraint is required because Siren uses PHP 8.1 features.

The root constant SIREN_YOUR_EXTENSION_ROOT stores __FILE__, which your Integration::getRootPath() returns. Siren uses this to locate templates and resources relative to your plugin.

The guard clause if (! function_exists('add_action')) prevents the file from executing outside WordPress. Some extensions use if (! defined('ABSPATH')) instead — both work. This guard is mandatory.

The siren_ready hook at priority 0 calls Extensions::add() with a unique string ID and a callable factory that returns your Integration instance. The factory pattern is intentional — your class is not instantiated until Siren is ready to process extensions, keeping registration lightweight and avoiding loading classes before their dependencies are available.

What to Customize

  • All metadata in the plugin header
  • The constant name (match your extension name)
  • The use statements (match your namespace)
  • If your extension needs its own Composer autoloader, add require_once __DIR__ . '/vendor/autoload.php'; before the siren_ready hook

composer.json (Namespace and Dependencies)

Anatomy

{
    "name": "novatorius/siren-extension-template",
    "autoload": {
        "psr-4": {
            "Novatorius\\SirenExtensionTemplate\\": "lib/"
        }
    },
    "require": {
        "php": ">=8.1"
    }
}

What to Customize

Update the name field to your vendor/package name. Map your root namespace to the lib/ directory in the PSR-4 section — every class in lib/ must use this namespace as its root. Add any Composer dependencies to the require section, but you do not need to require Siren itself — it will already be loaded when your extension runs.

Do not add phpnomad/* packages to your require section. Siren bundles these, and requiring them separately risks version conflicts. Your extension’s classes can use PHPNomad interfaces (like CanHandle, HasEventBindings, etc.) because they are already loaded by Siren’s autoloader.


lib/Integration.php (Main Extension Class)

This is the heart of your extension. It implements the interfaces that tell Siren what your extension does and how to load it.

Interface Choices

The template implements all five common interfaces:

class Integration implements
    Extension,           // Required -- identifies this as a Siren extension
    HasEventBindings,    // Maps WordPress hooks to domain events
    HasListeners,        // Attaches handlers to domain events
    HasLoadCondition,    // Gates loading behind a condition check
    CanSetContainer,     // Receives the DI container
    Loadable             // Runs imperative setup after all bindings
{
    use HasSettableContainer; // Satisfies CanSetContainer
}

You do not need all of them. Only implement what your extension uses:

Building…Required InterfacesOptional Interfaces
Third-party integrationExtension, HasEventBindings, HasLoadCondition, CanSetContainerLoadable, HasListeners
Feature extensionExtension, HasListeners, HasLoadCondition, CanSetContainerLoadable, HasEventBindings
Minimal extensionExtension, HasLoadConditionEverything else

Method Reference

Get ID

Static method. Returns the unique short identifier for this extension.

public static function getId(): string
{
    return 'my_plugin';
}

Conventions:

  • Lowercase, underscores or short abbreviations
  • Must be unique across all installed extensions
  • Used as the module ID for template path resolution (my_plugin::path/to/template)
  • Existing IDs in Siren: wc, edd, gf, lifterlms, learndash, nc, wordpress-core

Root path

Returns the filesystem path to the extension’s root. Always return the constant defined in plugin.php:

public function getRootPath(): string
{
    return SIREN_MY_PLUGIN_ROOT;
}

Name and description

Human-readable name and description, displayed in Siren’s admin:

public function getName(): string
{
    return 'My Plugin';
}

public function getDescription(): string
{
    return 'Integrates My Plugin with Siren Affiliates';
}

Activation check

Checks whether the extension’s dependencies are available. This is called by Siren’s admin even when the extension has not loaded. The most common pattern is a class existence check:

public function canActivate(): bool
{
    return class_exists('MyPluginMainClass');
}

For extensions that do not depend on a third party, return true or check for a configuration requirement.

Load condition

Called during the loading lifecycle. If this returns false, the extension is skipped entirely. Usually delegates to canActivate():

public function shouldLoad(): bool
{
    return $this->canActivate();
}

Override this separately when you need a different condition for “can it work?” vs. “should it load right now?” For example, you might check a feature flag or settings value.

Feature support

Declares which Siren features this extension provides:

use Siren\Extensions\Core\Enums\Features;

public function getSupports(): array
{
    return [
        Features::Coupons,
        Features::ManualOrdering,
    ];
}

Siren uses this to enable/disable UI features. For example, the coupon management UI only appears if at least one active extension declares Features::Coupons.

Active state

Returns whether the extension has finished loading. Set this in load():

protected bool $isActive = false;

public function getIsActive(): bool
{
    return $this->isActive;
}

public function load(): void
{
    $this->isActive = true;
    // ... other setup
}

Event bindings

Maps WordPress hooks to Siren domain events. See the Transformer Pattern section below for the full format.

Listeners

Maps domain events to handler classes. See the Handler Pattern section below for the full format.

Load method

Runs last in the interface processing order. Use this for imperative setup that does not fit the declarative interfaces. Examples include registering admin services and adding WordPress filters.

public function load(): void
{
    $this->isActive = true;
    add_action('plugins_loaded', fn() => $this->container->get(
        CouponAdminService::class
    )->init());
}

lib/Transformers/ (The Transformer Pattern)

Transformers are the bridge between platform-specific hooks and platform-agnostic domain events. They are unique to third-party integrations.

When to Use

Use a transformer when you need to convert a WordPress hook’s arguments into a Siren domain event. Every entry in getEventBindings() that includes a 'transformer' key points to a transformer method.

How It Works

WordPress fires hook A WordPress action like woocommerce_new_order is triggered
ActionBindingStrategy Calls your transformer with the hook's arguments
Transformer lookup Looks up the affiliate opportunity
Build domain event Transformer returns a domain event (or null to skip)
Broadcast Siren broadcasts the event to all registered listeners

Structure

class SaleTriggeredTransformer
{
    // Dependencies injected by the DI container
    protected OpportunityLocatorService $opportunityLocator;
    protected VisitorOpportunity $visitorLocators;
    protected OrderToTransactionDetailsAdapter $detailsAdapter;

    public function __construct(
        OpportunityLocatorService $opportunityLocator,
        VisitorOpportunity $visitorLocators,
        OrderToTransactionDetailsAdapter $detailsAdapter
    ) {
        $this->opportunityLocator = $opportunityLocator;
        $this->visitorLocators = $visitorLocators;
        $this->detailsAdapter = $detailsAdapter;
    }

    public function getSaleTriggeredEvent(int $orderId): ?SaleTriggered
    {
        // Platform-specific: get the customer from your plugin
        $userId = my_plugin_get_order_user_id($orderId);

        // Platform-agnostic: find the affiliate opportunity
        $opportunity = $this->opportunityLocator->locateUsing(
            ...$this->visitorLocators->build($userId)
        );

        if (!$opportunity) {
            return null; // No affiliate involved -- skip
        }

        // Build the domain event
        return new SaleTriggered(
            $opportunity->getId(),
            $this->detailsAdapter->toArray($orderId),
            'my_plugin',
            (string) $orderId,
            'my_plugin_order'
        );
    }
}

Key Rules

Transformers are resolved from the DI container, so their constructors are auto-wired — just declare your dependencies as constructor parameters. Return null to skip if the hook fired but there is no affiliate opportunity or the event should not be tracked. The transformer method’s parameter list must match the WordPress hook’s arguments: if my_plugin_order_completed passes ($orderId, $orderData), your transformer must accept those same parameters. You can use the same transformer for multiple hooks bound to the same event type as long as the argument format is compatible — create separate transformers when formats differ.

How Transformers Connect to Event Bindings

In your Integration::getEventBindings():

$callback = fn($orderId) => $this->container->get(
    SaleTriggeredTransformer::class
)->getSaleTriggeredEvent($orderId);

return [
    SaleTriggered::class => [
        ['action' => 'my_plugin_order_completed', 'transformer' => $callback],
    ],
];

The closure wraps the container resolution. This ensures the transformer is only instantiated when the hook actually fires, not when bindings are registered.

When to Delete

If your extension is a feature extension that does not integrate a third party, you do not need transformers. Delete the lib/Transformers/ directory and remove HasEventBindings from your Integration class.


lib/Adapters/ (The Adapter Pattern)

Adapters convert data from one format to another. In the extension context, they typically convert platform-specific data structures into Siren’s standardized formats.

When to Use

Use an adapter when you need to reshape data from the integrated plugin into a format Siren understands. The most common case is converting order/payment data into Siren’s transaction detail format.

Structure

class OrderToTransactionDetailsAdapter
{
    protected FloatToIntPriceAdapter $priceAdapter;

    public function __construct(FloatToIntPriceAdapter $priceAdapter)
    {
        $this->priceAdapter = $priceAdapter;
    }

    public function toArray(int $orderId): array
    {
        $order = my_plugin_get_order($orderId);
        $result = [];

        foreach ($order->getItems() as $item) {
            $result[] = [
                'name'        => $item->getName(),
                'description' => $item->getQuantity() . ' X ' . $item->getName(),
                'type'        => 'product',
                'value'       => $this->priceAdapter->toInt($item->getPrice()),
                'quantity'    => $item->getQuantity(),
                'units'       => $order->getCurrency(),
                'externalId'  => (string) $item->getId(),
            ];
        }

        return $result;
    }
}

Key Rules

Adapters are pure data converters — they should not contain business logic, side effects, or event dispatching. Always use FloatToIntPriceAdapter for price conversions because Siren stores monetary values as integers in the smallest currency unit. Adapters are injected into transformers, which call them to get formatted data and then pass that data into the domain event constructor.

Transaction Detail Format

The standard format for transaction line items:

[
    'name'        => 'Product Name',        // Required
    'description' => '2 X Product Name',    // Required
    'type'        => 'product',             // Required: product|shipping|tax|fee|discount
    'value'       => 2999,                  // Required: price in cents (integer)
    'quantity'    => 2,                      // Required: number of units
    'units'       => 'USD',                 // Required: currency code
    'externalId'  => '123',                 // Optional: ID in source system
    'attributes'  => [                      // Optional: extra metadata
        'collaborators' => [1, 2],          // Collaborator IDs bound to product
        'categories'    => ['digital'],     // Product categories
        'sku'           => 'PROD-001',      // SKU
    ],
]

When to Delete

If your extension does not transform external data into Siren formats, you do not need adapters. Feature extensions that only listen to domain events typically have no adapters. Delete the lib/Adapters/ directory.


lib/Handlers/ (The Handler Pattern)

Handlers (also called listeners) react to domain events. They implement PHPNomad\Events\Interfaces\CanHandle and are registered through the HasListeners interface on your Integration class.

When to Use

Use a handler when your extension needs to react to something that happens in Siren or another extension. Common use cases:

  • Initialize platform-specific services when Siren is ready (Ready event)
  • Perform cleanup when a transaction is refunded
  • Sync data to an external system when an event fires

Structure

<?php

namespace YourVendor\SirenMyPlugin\Handlers;

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

class AdminHandler implements CanHandle
{
    // Dependencies injected by the DI container
    protected SomeService $service;

    public function __construct(SomeService $service)
    {
        $this->service = $service;
    }

    public function handle(Event $event): void
    {
        // React to the event
        $this->service->doSomething();
    }
}

How Handlers Connect to Listeners

In your Integration::getListeners():

use PHPNomad\Core\Events\Ready;

public function getListeners(): array
{
    return [
        Ready::class => [AdminHandler::class],
        // Or a single handler (not wrapped in array):
        // Ready::class => AdminHandler::class,
    ];
}

The format is:

[
    EventClass::class => HandlerClass::class,
    // or for multiple handlers on the same event:
    EventClass::class => [
        FirstHandler::class,
        SecondHandler::class,
    ],
]

Key Rules

Handlers are resolved from the DI container with auto-wired constructor dependencies, which is why the Integration class only declares class names rather than instantiating handlers directly. The handle() method receives the specific event class even though the signature says Event $event — type-check with instanceof if you need event-specific methods:

public function handle(Event $event): void
{
    if ($event instanceof SaleTriggered) {
        $opportunityId = $event->getOpportunityId();
    }
}

Keep handlers single-purpose: one handler, one job. If you need to do three things when a sale fires, create three handlers. And always catch exceptions in handlers — uncaught exceptions bubble up and can prevent other handlers from running.

Common Domain Events

EventWhen It Fires
PHPNomad\Core\Events\ReadySiren has fully initialized
Siren\Commerce\Events\SaleTriggeredA sale is detected
Siren\Commerce\Events\TransactionCompletedA transaction is finalized
Siren\Commerce\Events\RefundTriggeredA refund is processed
Siren\Commerce\Events\RenewalTriggeredA subscription renewal occurs
Siren\Commerce\Events\CouponAppliedAn affiliate coupon is used
Siren\Commerce\Events\LeadTriggeredA lead/form submission occurs
Siren\Extensions\Core\Events\ExtensionInitExtensions are being initialized

When to Delete

If your extension only produces domain events (via event bindings and transformers) and does not need to react to them, you do not need handlers. Delete the lib/Handlers/ directory and remove HasListeners from your Integration class.


Choosing What to Keep

Not every extension needs every pattern. Here is a decision guide:

Integration Extension (e.g., WooCommerce, EDD)

Typical minimum:

  • plugin.php. Always required.
  • composer.json. Always required.
  • lib/Integration.php. Implements Extension, HasEventBindings, HasLoadCondition, CanSetContainer, Loadable.
  • lib/Transformers/. One transformer per event type you produce.
  • lib/Adapters/. One adapter per data format you convert.
  • lib/Handlers/. Only needed if you also react to events (e.g., initializing a third-party addon).

Feature Extension (e.g., custom reporting, notification service)

Typical minimum:

  • plugin.php. Always required.
  • composer.json. Always required.
  • lib/Integration.php. Implements Extension, HasListeners, HasLoadCondition, CanSetContainer, Loadable.
  • lib/Handlers/. One handler per event you react to.
  • No lib/Transformers/. You are not bridging platform hooks.
  • No lib/Adapters/. Only needed if you convert data between formats.

Minimal Extension (e.g., adds admin UI only)

Bare minimum:

  • plugin.php. Always required.
  • composer.json. Always required.
  • lib/Integration.php. Implements Extension, HasLoadCondition, Loadable.
  • Everything goes in load(). Register admin hooks, enqueue scripts, etc.
  • No transformers, adapters, or handlers

Extending Beyond the Template

The template covers the most common patterns, but extensions can also use these interfaces on their Integration class:

InterfacePurpose
HasClassDefinitionsRegister DI container bindings (interface to concrete mappings)
HasControllersRegister REST API routes
HasCommandsRegister CLI commands
HasUpdatesRegister database migration/upgrade routines
HasFacadesRegister facade instances
HasMutationsAttach mutation callbacks
HasTaskHandlersRegister background task handlers

These follow the same pattern: implement the interface, return the configuration array from the required method, and the loader processes it automatically during the interface processing order.