Siren

The Mapping System

Using Mapping to bridge Siren internal IDs with external system IDs.

Last updated: April 6, 2026

The Mapping System

Mappings are Siren’s mechanism for bridging internal IDs with external system IDs. When WooCommerce creates an order, EDD processes a payment, or NorthCommerce sells a product, the mapping system records the relationship between Siren’s internal identifiers (such as transactions and collaborators) and the external platform’s identifiers. This enables duplicate detection, reverse lookups, and clean separation between Siren’s domain and third-party systems.

The Mapping Model

The Mapping model lives at lib/Mappings/Core/Models/Mapping.php:

namespace Siren\Mappings\Core\Models;

use PHPNomad\Datastore\Interfaces\DataModel;

final class Mapping implements DataModel
{
    protected int $localId;
    protected $externalId;       // string|int
    protected string $localType;
    protected string $externalType;

    public function __construct(
        int $localId,
        $externalId,
        string $localType,
        string $externalType
    ) { /* ... */ }

    public function getLocalId(): int { return $this->localId; }
    public function getExternalId() { return $this->externalId; }
    public function getLocalType(): string { return $this->localType; }
    public function getExternalType(): string { return $this->externalType; }

    public function getIdentity(): array
    {
        return [
            'localId'      => $this->getLocalId(),
            'externalId'   => $this->getExternalId(),
            'localType'    => $this->getLocalType(),
            'externalType' => $this->getExternalType(),
        ];
    }
}

The Four Fields

FieldTypeDescriptionExample
localIdintSiren’s internal IDCollaborator ID 42, Transaction ID 15
externalIdstring|intThe external system’s IDWooCommerce order ID 1087, product ID 55
localTypestringWhat kind of Siren entity'collaborator', 'transaction'
externalTypestringWhat kind of external entity'wc_order', 'wc_product', 'nc_product'

The combination of all four fields forms the compound primary key. There are no auto-increment IDs on the mappings table.

Common Use Cases

Product-to-Collaborator Mappings

When a store owner assigns collaborators to products in the admin UI, each assignment is stored as a mapping:

localIdexternalIdlocalTypeexternalType
4255collaboratorwc_product
4212collaboratornc_product
755collaboratorwc_product

Order-to-Transaction Mappings

When a WooCommerce order generates a Siren transaction, the relationship is recorded:

localIdexternalIdlocalTypeexternalType
151087transactionwc_order

Naming conventions for types

The localType is always a Siren domain entity in lowercase (collaborator, transaction, program). The externalType uses a platform prefix plus entity name (wc_product, wc_order, nc_product, edd_payment).

How do I query and manage mappings?

The MappingDatastore interface (lib/Mappings/Core/Datastores/Mapping/Interfaces/MappingDatastore.php) provides purpose-built query methods:

interface MappingDatastore extends Datastore, DatastoreHasWhere, DatastoreHasCounts
{
    /**
     * Find a mapping by all four fields (exact match).
     * @throws RecordNotFoundException
     */
    public function find(
        int $localId, string $localType,
        int $externalId, string $externalType
    ): Mapping;

    /**
     * Find a mapping by its Siren-side identity.
     * @throws RecordNotFoundException
     */
    public function getByLocalId(
        int $localId, string $localType, string $externalType
    ): Mapping;

    /**
     * Find a mapping by its external-system identity.
     * @throws RecordNotFoundException
     */
    public function getByExternalId(
        $externalId, string $externalType, string $localType
    ): Mapping;

    /** Delete a specific mapping by all four fields. */
    public function delete(
        int $localId, string $localType,
        int $externalId, string $externalType
    ): void;

    /** Delete all mappings for a given Siren entity. */
    public function deleteMappingsForLocalId(int $localId, string $localType): void;

    /** Delete all mappings for a given external entity. */
    public function deleteMappingsForExternalId(int $externalId, string $externalType): void;
}

How Queries Work Internally

The concrete MappingDatastore delegates to the datastore handler using andWhere():

public function getByLocalId(int $localId, string $localType, string $externalType): Mapping
{
    $models = $this->datastoreHandler->andWhere([
        ['column' => 'localId', 'operator' => '=', 'value' => $localId],
        ['column' => 'localType', 'operator' => '=', 'value' => $localType],
        ['column' => 'externalType', 'operator' => '=', 'value' => $externalType]
    ]);

    if (empty($models)) {
        throw new RecordNotFoundException('The local mapping could not be found.');
    }

    return Arr::get($models, 0);
}

Creating Mappings

Use the standard datastore create() method:

$this->mappings->create([
    'externalId'   => $product->get_id(),
    'localId'      => $collaborator->getId(),
    'localType'    => 'collaborator',
    'externalType' => 'wc_product'
]);

Updating Mappings: The Clear-Then-Recreate Pattern

Siren does not update mappings in place. Instead, it deletes existing mappings and creates new ones. This is the standard pattern used in every admin save handler:

// From WooCommerce/Services/ProductAdminService::handleSave()
protected function handleSave($product)
{
    try {
        if (isset($_POST['siren-product-collaborators']) && $product instanceof WC_Product) {
            $collaborators = $this->collaborators->findMultiple(
                Arr::cast(array_map('absint', wp_unslash($_POST['siren-product-collaborators'])), 'int')
            );

            // Step 1: Clear existing mappings for this product
            $this->mappings->deleteWhere([
                ['column' => 'externalId', 'operator' => '=', 'value' => $product->get_id()],
                ['column' => 'externalType', 'operator' => '=', 'value' => 'wc_product'],
                ['column' => 'localType', 'operator' => '=', 'value' => 'collaborator']
            ]);

            // Step 2: Create fresh mappings
            foreach ($collaborators as $collaborator) {
                $this->mappings->create([
                    'externalId'   => $product->get_id(),
                    'localId'      => $collaborator->getId(),
                    'localType'    => 'collaborator',
                    'externalType' => 'wc_product'
                ]);
            }
        }
    } catch (DatastoreErrorException $e) {
        $this->logger->logException($e);
    }
}

NorthCommerce follows the same pattern, using nc_product as the externalType:

// From NorthCommerce/Services/ProductAdminService::handleSave()
$this->mappings->deleteWhere([
    ['column' => 'externalId', 'operator' => '=', 'value' => $productId],
    ['column' => 'externalType', 'operator' => '=', 'value' => 'nc_product'],
    ['column' => 'localType', 'operator' => '=', 'value' => 'collaborator'],
]);

foreach ($collaborators as $collaborator) {
    $this->mappings->create([
        'externalId'   => $productId,
        'localId'      => $collaborator->getId(),
        'localType'    => 'collaborator',
        'externalType' => 'nc_product',
    ]);
}

Duplicate Prevention Using Mappings

The most important use of mappings is preventing duplicate transaction processing. When a WooCommerce order fires sale events (which can happen multiple times due to status transitions), the transformer checks for an existing mapping before creating a new transaction:

// From WooCommerce/Services/TransactionTransformerService.php
protected function orderHasTransaction(int $orderId)
{
    try {
        $this->mappings->getByExternalId($orderId, 'wc_order', 'transaction');
        return true;
    } catch (RecordNotFoundException $e) {
        // No mapping found -- this order hasn't been processed yet
    } catch (DatastoreErrorException $e) {
        $this->logger->logException($e);
    }

    return false;
}

public function getSaleTriggeredEvent($orderId): ?SaleTriggered
{
    $orderId = WC()->order_factory->get_order_id($orderId);

    if (!$orderId) {
        return null;
    }

    $opportunity = $this->locateOpportunity($orderId);

    if (!$opportunity) {
        return null;
    }

    // Guard: don't create duplicate transactions
    if ($this->orderHasTransaction($orderId)) {
        return null;
    }

    $transactionDetails = $this->detailsAdapter->toArray($orderId);

    if (empty($transactionDetails)) {
        return null;
    }

    return new SaleTriggered(
        $opportunity->getId(),
        $transactionDetails,
        'wc',
        $orderId,
        'wc_order'
    );
}

The flow:

  1. WooCommerce fires woocommerce_order_status_processing.
  2. The transformer calls orderHasTransaction($orderId).
  3. If a mapping exists (wc_order:1087 -> transaction), return null. The order is a duplicate.
  4. If no mapping, proceed with creating the SaleTriggered event.
  5. Downstream, when the transaction is created, a mapping is written.

Cleaning Up Mappings on Deletion

NorthCommerce handles product deletion by removing all related mappings:

protected function handleDelete(int $productId): void
{
    try {
        if (!empty($productId)) {
            $this->mappings->deleteWhere([
                ['column' => 'externalId', 'operator' => '=', 'value' => $productId],
                ['column' => 'externalType', 'operator' => '=', 'value' => 'nc_product'],
                ['column' => 'localType', 'operator' => '=', 'value' => 'collaborator'],
            ]);
        }
    } catch (DatastoreErrorException $e) {
        $this->logger->logException($e);
    }
}

You can also use the convenience methods on MappingDatastore:

// Delete all mappings for a Siren collaborator
$this->mappings->deleteMappingsForLocalId($collaboratorId, 'collaborator');

// Delete all mappings for a WooCommerce product
$this->mappings->deleteMappingsForExternalId($productId, 'wc_product');

The Mappings Table Schema

The mappings table uses a compound primary key with no auto-increment ID:

// From MappingsTable
public function getColumns(): array
{
    return $this->mergeTenantColumns([
        new Column('localId', 'BIGINT', null, 'NOT NULL'),
        new Column('externalId', 'CHAR', [32], 'NOT NULL'),
        new Column('localType', 'VARCHAR', [255], 'NOT NULL'),
        new Column('externalType', 'VARCHAR', [255], 'NOT NULL'),
    ]);
}

public function getIndices(): array
{
    $identity = ['localId', 'externalId', 'localType', 'externalType'];
    return $this->mergeTenantIndices([
        new Index($identity, '', 'PRIMARY KEY'),
        new Index($identity, 'identity_index', 'INDEX')
    ]);
}

Note that externalId is CHAR(32), not BIGINT. This accommodates external systems that use non-numeric identifiers (UUIDs, slugs, etc.).

How do I access the mapping datastore in my code?

Inject MappingDatastore via the interface:

use Siren\Mappings\Core\Datastores\Mapping\Interfaces\MappingDatastore;

class MyService
{
    protected MappingDatastore $mappings;

    public function __construct(MappingDatastore $mappings)
    {
        $this->mappings = $mappings;
    }
}

The DI container resolves the concrete implementation automatically. The mapping domain’s Initializer handles the bindings:

// From Mappings/Service/Initializer.php
public function getClassDefinitions(): array
{
    return [
        MappingDatabaseDatastoreHandler::class => MappingDatastoreHandler::class,
        MappingDatastore::class => MappingDatastoreInterface::class,
    ];
}

Checklist for Using Mappings in a New Extension

  1. Inject MappingDatastore into your service or admin service.
  2. Define consistent localType and externalType strings for your integration.
  3. Use clear-then-recreate on save operations to keep mappings in sync.
  4. Use getByExternalId() for duplicate detection before creating records.
  5. Clean up mappings when external entities are deleted.
  6. Wrap all mapping operations in try/catch for DatastoreErrorException.