Siren

Quickstart: Your First Extension

Step-by-step walkthrough to scaffold, configure, and activate your first Siren extension.

Last updated: April 6, 2026

Quickstart: Your First Extension

This tutorial walks you through building a Siren extension from the template repository. Siren’s extension system is built on PHPNomad, a platform-agnostic PHP framework. By the end, you will have a working extension that listens to a WordPress hook and triggers a Siren domain event.

What You Will Build

A simple integration that fires a SaleTriggered domain event when your custom plugin processes an order. This is the most common extension pattern. It bridges a third-party commerce system into Siren’s affiliate tracking.

Prerequisites

  • A WordPress development environment with Siren installed and activated
  • Composer installed globally
  • Basic familiarity with PHP 8.1+ and WordPress plugin development
  • Understanding of Siren’s Extension Architecture

Step 1: Clone the Template

cd wp-content/plugins/
git clone https://github.com/Novatorius/siren-extension-template.git siren-my-plugin
cd siren-my-plugin
rm -rf .git
git init

You now have a plugin directory with this structure:

siren-my-plugin/
  plugin.php
  composer.json
  lib/
    Integration.php
    Transformers/
      SaleTriggeredTransformer.php
    Adapters/
      OrderToTransactionDetailsAdapter.php
    Handlers/
      AdminHandler.php

Step 2: Find-and-Replace Naming Placeholders

The template uses placeholder names that you need to replace with your own. Do a project-wide find-and-replace for each of these, in this order:

FindReplace WithWhere It Appears
YourExtensionMyPluginNamespace segments, class references
Your ExtensionMy PluginPlugin header, human-readable names
your-extensionmy-pluginPlugin slug, directory references
your_extensionmy_pluginConstants, function prefixes
your_extmy_pluginShort identifiers
Novatorius\\SirenExtensionTemplateYourVendor\\SirenMyPluginNamespace root in composer.json

Be methodical. Check every file. A missed replacement will cause autoloading failures or namespace collisions.

Step 3: Update plugin.php Metadata

Open plugin.php and update the WordPress plugin header:

<?php
/*
 * Plugin Name: Siren Affiliates - My Plugin Integration
 * Description: Integrates My Plugin with Siren Affiliates.
 * Author: Your Name
 * Author URI: https://yoursite.com
 * Version: 1.0.0
 * Requires PHP: 8.1
 */

The rest of the file should already look like this after your find-and-replace:

use Siren\Extensions\Core\Facades\Extensions;
use YourVendor\SirenMyPlugin\Integration;

const SIREN_MY_PLUGIN_ROOT = __FILE__;

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

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

The early return on add_action prevents the file from executing outside WordPress. This guard is required. Extensions must register at priority 0 on siren_ready so all extensions are registered before Siren processes them. The callable passed to Extensions::add() is a lazy factory. Your Integration class is not instantiated until Siren is ready to process it, keeping registration cheap.

Step 4: Configure composer.json

Update the namespace mapping so Composer’s autoloader can find your classes:

{
    "name": "your-vendor/siren-my-plugin",
    "autoload": {
        "psr-4": {
            "YourVendor\\SirenMyPlugin\\": "lib/"
        }
    },
    "require": {
        "php": ">=8.1"
    }
}

Then install:

composer install

This generates the vendor/autoload.php that your plugin.php should require. If the template does not include a require statement for the autoloader, add one before the siren_ready hook:

require_once __DIR__ . '/vendor/autoload.php';

Step 5: Set up activation and load conditions

Open lib/Integration.php. The two gating methods control whether your extension loads:

public function canActivate(): bool
{
    // Return true if the third-party plugin your extension
    // integrates with is installed and available.
    return class_exists('MyPluginMainClass');
}

public function shouldLoad(): bool
{
    // For most integrations, this mirrors canActivate().
    // Use a different condition if you need finer control
    // (e.g., checking a settings flag).
    return $this->canActivate();
}

canActivate() is used by Siren’s admin UI to show whether the extension could work. Are the dependencies present? shouldLoad() is checked by the loader to decide whether to actually process the extension right now. Usually it delegates to canActivate(), but you might add additional checks like verifying that a required API key is configured.

If shouldLoad() returns false, the entire extension is skipped: no event bindings, no listeners, no load() call.

Step 6: Declare What Your Extension Supports

The getSupports() method tells Siren what capabilities your extension provides. This affects which features are available in the admin UI:

use Siren\Extensions\Core\Enums\Features;

public function getSupports(): array
{
    return [
        Features::ManualOrdering,
        // Add Features::Coupons if your plugin has coupon support
        // Add Features::Renewals if your plugin has subscription support
    ];
}

Available feature constants:

ConstantMeaning
Features::CouponsExtension can apply affiliate coupons
Features::CoursesExtension tracks course enrollments
Features::LessonsExtension tracks lesson completions
Features::PostsExtension tracks post interactions
Features::RenewalsExtension tracks subscription renewals
Features::FormsExtension tracks form submissions
Features::ManualOrderingExtension supports manual order creation

Step 7: Add Your First Event Binding

Event bindings are the core of a third-party integration. They map WordPress hooks from the integrated plugin to Siren domain events.

In lib/Integration.php, implement getEventBindings():

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

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

This tells Siren: “When the WordPress action my_plugin_order_completed fires, call my transformer. If the transformer returns a SaleTriggered event, broadcast it.”

The binding format is:

[
    DomainEventClass::class => [
        [
            'action' => 'wordpress_hook_name',
            'transformer' => callable,
        ],
        // You can bind multiple hooks to the same event
    ],
]

The transformer callable receives whatever arguments WordPress passes to the hook. It must return either a domain event instance or null (to silently skip).

Step 8: Create the Transformer

The transformer bridges the gap between the WordPress hook’s raw arguments and Siren’s domain event. Open lib/Transformers/SaleTriggeredTransformer.php:

<?php

namespace YourVendor\SirenMyPlugin\Transformers;

use Siren\Commerce\Events\SaleTriggered;
use Siren\Opportunities\Core\Interfaces\OpportunityLocatorService;
use Siren\Opportunities\Service\LocatorGroups\VisitorOpportunity;
use YourVendor\SirenMyPlugin\Adapters\OrderToTransactionDetailsAdapter;

class SaleTriggeredTransformer
{
    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
    {
        // 1. Get the customer's user ID from your plugin's order
        $userId = my_plugin_get_order_user_id($orderId);

        // 2. Find the affiliate opportunity for this visitor
        $opportunity = $this->opportunityLocator->locateUsing(
            ...$this->visitorLocators->build($userId)
        );

        // No opportunity means no affiliate referred this customer
        if (!$opportunity) {
            return null;
        }

        // 3. Build the domain event
        return new SaleTriggered(
            $opportunity->getId(),
            $this->detailsAdapter->toArray($orderId),
            'my_plugin',        // source identifier
            (string) $orderId,  // external binding ID
            'my_plugin_order'   // external binding type
        );
    }
}

All three constructor dependencies are resolved automatically by the DI container. If no affiliate opportunity exists for the order, the transformer returns null and Siren silently skips the event. The source identifier ('my_plugin') tags the event so Siren knows which integration generated it, and the binding ID and type let Siren deduplicate. If the same order fires the hook twice, Siren can detect the duplicate.

Step 9: Create the Adapter

The adapter converts your plugin’s order data into Siren’s transaction detail format. Open lib/Adapters/OrderToTransactionDetailsAdapter.php:

<?php

namespace YourVendor\SirenMyPlugin\Adapters;

use Siren\Commerce\Adapters\FloatToIntPriceAdapter;

class OrderToTransactionDetailsAdapter
{
    protected FloatToIntPriceAdapter $priceAdapter;

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

    /**
     * Convert an order into Siren's transaction details format.
     *
     * @param int $orderId
     * @return array
     */
    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'  => $item->getId(),
            ];
        }

        return $result;
    }
}

Siren stores prices as integers (cents, not dollars). Use FloatToIntPriceAdapter::toInt() to convert 29.99 to 2999.

The transaction detail array format:

FieldTypeDescription
namestringLine item display name
descriptionstringHuman-readable description
typestringproduct, shipping, tax, fee, or discount
valueintPrice in smallest currency unit (cents)
quantityintNumber of units
unitsstringCurrency code (e.g., USD)
externalIdstring/nullID in the source system

Step 10: Activate and Test

Activate your extension in the WordPress plugin admin — it should appear as a separate plugin. Then go to the Siren admin and verify your extension shows in the extensions list. If canActivate() returns true and Siren has loaded, the extension should show as active.

To test the event flow, create a test affiliate link in Siren, visit your site through that link to create an opportunity, complete an order through your plugin, and check the Siren admin for a new transaction.

If something isn’t working, start with the basics. If the extension doesn’t appear at all, make sure siren_ready is firing (Siren must be active). If it appears but shows as inactive, check your canActivate() method — the third-party plugin may not be detected. If events aren’t firing, verify the WordPress hook name in your event bindings matches exactly what your plugin fires. And if the transformer returns null, the event is silently skipped — add logging to diagnose opportunity lookup failures.

What to Do Next

If your plugin has coupon support, implement the coupon admin service pattern so affiliates can use coupon codes — see the WooCommerce and EDD extensions for examples. If your extension needs to react to Siren domain events (not just produce them), implement listeners and create handler classes. You can also use the load method to register admin services that hook into WordPress’s admin pages. The Template Reference explains every file in the template and when to use each pattern.