Adapters
Converting plugin-specific data formats into Siren's standardized transaction detail structure.
Last updated: April 6, 2026
Adapters
Adapters convert plugin-specific data formats into Siren’s standardized structures. While transformers handle events (deciding whether and when to fire a domain event), adapters handle data (converting the shape of platform-specific objects into the shape Siren’s domain layer expects).
The primary adapter in every commerce extension is OrderToTransactionDetailsAdapter, which converts an order’s line items, taxes, shipping, discounts, and fees into a standardized array format that the SaleTriggered and RenewalTriggered events carry into the domain layer.
How do adapters differ from transformers?
| Concern | Transformer | Adapter |
|---|---|---|
| Scope | Decides whether to fire a domain event | Converts data formats for domain consumption |
| Input | WordPress hook parameters (order ID, etc.) | Platform-specific data objects (order, line items) |
| Output | Domain event instance or null | Standardized data array |
| Business logic | Opportunity location, duplicate prevention | Price conversion, type mapping, attribute extraction |
| Called by | The framework (via event binding callbacks) | Transformers (as a dependency) |
A transformer uses an adapter. The typical flow:
WordPress hook fires
-> Transformer receives hook parameters
-> Transformer checks opportunity + duplicates
-> Transformer calls adapter to convert order data
-> Transformer constructs domain event with adapted data
What format should transaction details use?
Each line item is represented as an associative array with these fields:
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name of the item |
description | string | Yes | Longer description |
type | string | Yes | Item type (see below) |
value | int | Yes | Price per unit in cents |
quantity | int | Yes | Number of units |
units | string | Yes | Currency code (e.g., 'USD', 'EUR') |
externalId | string|null | No | Platform-specific product/item ID |
attributes | array | No | Additional metadata (collaborators, categories, SKU) |
Item Types
| Type | Description |
|---|---|
product | A standard product line item |
subscription | A subscription product (WooCommerce Subscriptions) |
shipping | Shipping charges |
tax | Tax charges |
discount | Discount amount (value is negative) |
fee | Additional fees (signup fees, service fees, etc.) |
Price Format: Cents as Integers
All monetary values must be integers representing the smallest currency unit (cents for USD/EUR, pence for GBP, etc.). A $29.99 item has a value of 2999.
Siren provides FloatToIntPriceAdapter to handle the conversion:
use Siren\Commerce\Adapters\FloatToIntPriceAdapter;
$priceAdapter = new FloatToIntPriceAdapter();
$priceAdapter->toInt(29.99); // Returns 2999
$priceAdapter->toInt(0.50); // Returns 50
$priceAdapter->toFloat(2999); // Returns 29.99
The adapter multiplies by 100 and casts to int. Always use this adapter rather than doing the math yourself to ensure consistent rounding behavior.
Discounts Are Negative
Discount line items use a negative value to indicate a reduction:
$discount = $this->priceAdapter->toInt($order->get_discount_total());
if ($discount > 0) {
$result[] = [
'name' => 'Discount',
'description' => 'Discount Total',
'type' => 'discount',
'value' => $discount * -1, // Negative value
'quantity' => 1,
'units' => $currency,
'externalId' => null
];
}
How does the WooCommerce adapter work?
The WooCommerce OrderToTransactionDetailsAdapter shows the complete pattern. Here is its toArray() method broken down by section:
Constructor and Dependencies
class OrderToTransactionDetailsAdapter
{
protected FloatToIntPriceAdapter $priceAdapter;
protected MappingDatastore $mappings;
protected LoggerStrategy $logger;
public function __construct(
FloatToIntPriceAdapter $priceAdapter,
MappingDatastore $mappings,
LoggerStrategy $logger
) {
$this->mappings = $mappings;
$this->priceAdapter = $priceAdapter;
$this->logger = $logger;
}
}
The adapter depends on FloatToIntPriceAdapter for price conversion, MappingDatastore for looking up product-to-collaborator relationships, and LoggerStrategy for exception logging.
Shipping
$shippingTotal = $this->priceAdapter->toInt($order->get_shipping_total());
if ($shippingTotal > 0) {
$result[] = [
'name' => 'Shipping',
'description' => 'Shipping Fees',
'type' => 'shipping',
'value' => $shippingTotal,
'quantity' => 1,
'units' => $currency,
'externalId' => null
];
}
Shipping is a single line item. If the order has no shipping cost, it is omitted entirely (not included with a zero value).
Taxes
$totalTax = $this->priceAdapter->toInt($order->get_total_tax());
if ($totalTax > 0) {
$result[] = [
'name' => 'Taxes',
'description' => 'Total taxes',
'type' => 'tax',
'value' => $totalTax,
'quantity' => 1,
'units' => $currency,
'externalId' => null
];
}
Product Line Items
This is the most complex section — each product includes attributes for collaborator mappings and categories:
foreach ($lineItems as $id => $item) {
$total = $this->priceAdapter->toInt($item->get_total());
$product = $item->get_product();
if ($product && $total > 0) {
$terms = get_the_terms($product->get_id(), 'product_cat');
$categories = $terms ? Arr::pluck($terms, 'slug') : [];
$type = class_exists(WC_Subscriptions_Product::class)
&& WC_Subscriptions_Product::is_subscription($product)
? 'subscription'
: 'product';
$result[] = [
'name' => $item->get_name(),
'externalId' => $product->get_id(),
'description' => $item->get_quantity() . ' X Product ' . $item->get_name(),
'type' => $type,
'value' => $total / $item->get_quantity(), // Per-unit price
'quantity' => $item->get_quantity(),
'units' => $currency,
'attributes' => [
'collaborators' => $this->getProductCollaborators($item->get_product_id()),
'categories' => $categories,
'sku' => $product->get_sku() ? $product->get_sku() : null
]
];
}
}
The value field is per-unit, not the line total — if a customer buys 3 items at $30 total, each item’s value is 1000 (= $10.00 in cents). The type is dynamic: products are typed as 'subscription' when WooCommerce Subscriptions detects them as subscription products, otherwise 'product'. And externalId is the WooCommerce product ID, used for mapping back to the platform.
Fees
foreach ($fees as $item) {
$total = $this->priceAdapter->toInt($item->get_total());
if ($total > 0) {
$result[] = [
'name' => $item->get_name(),
'description' => $item->get_name(),
'type' => 'fee',
'value' => $total,
'quantity' => 1,
'units' => $currency,
'attributes' => [
'sku' => null
]
];
}
}
How do product-to-collaborator mappings work?
One of the most important adapter responsibilities is including collaborator mappings. When a store owner assigns specific affiliates (collaborators) to specific products, those assignments are stored as mappings. The adapter looks them up:
protected function getProductCollaborators($productId): array
{
try {
return Arr::pluck($this->mappings->andWhere([
['column' => 'externalId', 'operator' => '=', 'value' => $productId],
['column' => 'localType', 'operator' => '=', 'value' => 'collaborator'],
['column' => 'externalType', 'operator' => '=', 'value' => 'wc_product']
]), 'localId');
} catch (DatastoreErrorException $e) {
$this->logger->logException($e);
return [];
}
}
This returns an array of collaborator IDs associated with the product. The externalType uses the same convention described in the Transformers article — {extension_id}_{entity} (e.g., wc_product, edd_product).
The EDD adapter uses the same pattern with edd_product:
protected function getProductCollaborators($productId): array
{
try {
return Arr::pluck($this->mappings->andWhere([
['column' => 'externalId', 'operator' => '=', 'value' => $productId],
['column' => 'localType', 'operator' => '=', 'value' => 'collaborator'],
['column' => 'externalType', 'operator' => '=', 'value' => 'edd_product'],
]), 'localId');
} catch (DatastoreErrorException $e) {
$this->logger->logException($e);
return [];
}
}
How is category information extracted?
Product categories are extracted from the platform’s taxonomy system and included as slug arrays:
// WooCommerce — uses the 'product_cat' taxonomy
$terms = get_the_terms($product->get_id(), 'product_cat');
$categories = $terms ? Arr::pluck($terms, 'slug') : [];
// EDD — uses the 'download_category' taxonomy
$categories = wp_get_post_terms($itemId, 'download_category', ['fields' => 'slugs']);
Categories are stored in the attributes array and used by the domain layer for category-based commission rules.
What models store the adapter output?
After the domain layer processes the adapter output, the data is stored as TransactionDetail and TransactionDetailAttribute models:
TransactionDetail
final class TransactionDetail implements DataModel, HasSingleIntIdentity
{
protected int $transactionId;
protected string $description;
protected string $name;
protected Amount $value; // Value object wrapping int cents + currency
protected string $type; // 'product', 'shipping', 'tax', 'discount', 'fee'
protected string $units; // Currency code
protected int $quantity;
}
TransactionDetailAttribute
class TransactionDetailAttribute implements DataModel
{
protected int $transactionDetailId;
protected string $key; // 'collaborators', 'categories', 'sku'
protected $value; // array or scalar
}
The adapter output arrays map directly to these models. The attributes array key in the adapter output becomes multiple TransactionDetailAttribute records — one per key.
How does the EDD adapter differ?
The EDD adapter follows the same structure but handles EDD-specific concepts like order adjustments:
// EDD uses order adjustments for taxes, discounts, and credits
foreach ($order->order->adjustments as $adjustment) {
if ($adjustment->type === 'tax') {
$result[] = [
'name' => $adjustment->description,
'description' => $adjustment->description,
'type' => 'tax',
'value' => $this->priceAdapter->toInt($adjustment->amount),
'quantity' => 1,
'units' => $currency,
'externalId' => null,
];
} else if ($adjustment->type === 'discount') {
$result[] = [
'name' => $adjustment->description,
'description' => $adjustment->description,
'type' => 'discount',
'value' => $this->priceAdapter->toInt($adjustment->amount) * -1,
'quantity' => 1,
'units' => $currency,
'externalId' => null,
];
}
// ... credits mapped to 'discount', everything else to 'fee'
}
EDD also calculates shipping and signup fees from the payment’s fee array, separating them by fee ID:
public function calculateFees(\EDD_Payment $order)
{
$shippingTotal = $signupFee = 0;
if (count($order->fees) > 0) {
foreach ($order->fees as $fee) {
if (false !== strpos($fee['id'], 'simple_shipping')) {
$shippingTotal += $fee['amount'];
} elseif (false !== strpos($fee['id'], 'signup_fee')) {
$signupFee += $fee['amount'];
}
}
}
return [$shippingTotal, $signupFee];
}
How do I build a new adapter?
Your toArray() method should accept the platform order ID and retrieve the order object using the platform’s API. From there, extract shipping charges as a single aggregate line item, taxes (aggregated or itemized), and discounts (always multiply by -1 for the value). For product line items, include the externalId, calculate per-unit price, determine the type, and populate attributes with collaborators, categories, and SKU. Include any fees (signup fees, service fees, etc.) as separate line items. Use FloatToIntPriceAdapter for every price conversion — never multiply manually. Query MappingDatastore for product-to-collaborator relationships to populate the collaborators attribute. If the order cannot be loaded, return an empty array early.
The adapter should never throw exceptions. Wrap datastore calls in try/catch and return empty arrays or defaults on failure. The transformer decides whether an empty result should prevent event dispatch.