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
usestatements (match your namespace) - If your extension needs its own Composer autoloader, add
require_once __DIR__ . '/vendor/autoload.php';before thesiren_readyhook
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 Interfaces | Optional Interfaces |
|---|---|---|
| Third-party integration | Extension, HasEventBindings, HasLoadCondition, CanSetContainer | Loadable, HasListeners |
| Feature extension | Extension, HasListeners, HasLoadCondition, CanSetContainer | Loadable, HasEventBindings |
| Minimal extension | Extension, HasLoadCondition | Everything 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
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 (
Readyevent) - 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
| Event | When It Fires |
|---|---|
PHPNomad\Core\Events\Ready | Siren has fully initialized |
Siren\Commerce\Events\SaleTriggered | A sale is detected |
Siren\Commerce\Events\TransactionCompleted | A transaction is finalized |
Siren\Commerce\Events\RefundTriggered | A refund is processed |
Siren\Commerce\Events\RenewalTriggered | A subscription renewal occurs |
Siren\Commerce\Events\CouponApplied | An affiliate coupon is used |
Siren\Commerce\Events\LeadTriggered | A lead/form submission occurs |
Siren\Extensions\Core\Events\ExtensionInit | Extensions 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. ImplementsExtension,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. ImplementsExtension,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. ImplementsExtension,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:
| Interface | Purpose |
|---|---|
HasClassDefinitions | Register DI container bindings (interface to concrete mappings) |
HasControllers | Register REST API routes |
HasCommands | Register CLI commands |
HasUpdates | Register database migration/upgrade routines |
HasFacades | Register facade instances |
HasMutations | Attach mutation callbacks |
HasTaskHandlers | Register 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.
Related Documentation
- Extension Architecture Overview. How the extension system works.
- Quickstart: Your First Extension. Step-by-step tutorial.