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:
| Find | Replace With | Where It Appears |
|---|---|---|
YourExtension | MyPlugin | Namespace segments, class references |
Your Extension | My Plugin | Plugin header, human-readable names |
your-extension | my-plugin | Plugin slug, directory references |
your_extension | my_plugin | Constants, function prefixes |
your_ext | my_plugin | Short identifiers |
Novatorius\\SirenExtensionTemplate | YourVendor\\SirenMyPlugin | Namespace 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:
| Constant | Meaning |
|---|---|
Features::Coupons | Extension can apply affiliate coupons |
Features::Courses | Extension tracks course enrollments |
Features::Lessons | Extension tracks lesson completions |
Features::Posts | Extension tracks post interactions |
Features::Renewals | Extension tracks subscription renewals |
Features::Forms | Extension tracks form submissions |
Features::ManualOrdering | Extension 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:
| Field | Type | Description |
|---|---|---|
name | string | Line item display name |
description | string | Human-readable description |
type | string | product, shipping, tax, fee, or discount |
value | int | Price in smallest currency unit (cents) |
quantity | int | Number of units |
units | string | Currency code (e.g., USD) |
externalId | string/null | ID 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.