Siren

Custom calculation strategies

Registering a custom engagement-side or metric-side calculation strategy through the calc registry events.

Requires Siren Pro

Last updated: June 7, 2026

A calculation strategy decides who gets credited and how much when an engagement or metric trigger fires. The Fixed strategy that ships in the base tier wraps the historical one-credit-at-a-static-value behavior. The upline cascade and downline cascade strategies that ship in Pro walk a bound collaborator group and credit peers per layer. You can add your own strategy on either side by listening for a registry event and calling one method.

There are two parallel seams, one per side. The engagement side scores program engagements. The metric side scores distributor metrics. They mirror each other in shape, so the pattern below applies to both with the class names swapped. The one thing that is not interchangeable is the context object your calculate() reads from, which exposes different fields per side (see below).

The two registry events

Each side broadcasts a registry event once, on first read of its calculation provider service. You register your strategy by listening for that event and calling addCalculationStrategy(). Wire the listener through your Initializer’s getListeners() (shown below) so it is in place during plugin initialization, before that first read. A listener attached after the first read misses the one-time broadcast and your strategy never appears, with no error. If you are new to Siren’s event system, see the events introduction and listeners.

The engagement-side event is Siren\Engagements\Core\Events\EngagementCalculationRegistryInitiated (event id engagement_calculation_registry_initiated). The metric-side event is Siren\Metrics\Core\Events\MetricCalculationRegistryInitiated (event id metric_calculation_registry_initiated). Both carry the registry and a DI InstanceProvider, and both expose the same two methods:

/**
 * @param class-string<EngagementCalculationStrategy> $strategyClass
 */
public function addCalculationStrategy(string $strategyClass): void
{
    $this->registry->set($strategyClass::getId(), fn() => $this->provider->get($strategyClass));
}

public function deleteCalculationStrategy(string $id): void
{
    $this->registry->delete($id);
}

addCalculationStrategy() stores a lazy factory, so your strategy class is only instantiated when it is actually requested. The registry is keyed by $strategyClass::getId(), and registering the same id twice replaces the prior factory. Last writer wins. To remove or replace a built-in strategy, call deleteCalculationStrategy() with its id. Replacing a built-in under the same id is safe, because programs that stored that id still resolve to a strategy. Deleting an id that programs or distributors already use is not: their stored calculationType no longer resolves, so the calc credits no one and they pay out zero. Only delete an id you know is unused.

The interface a custom calc must implement

An engagement-side strategy implements Siren\Engagements\Core\Interfaces\EngagementCalculationStrategy, which extends Siren\Core\Core\Interfaces\HasRequiredArgs:

namespace Siren\Engagements\Core\Interfaces;

use Siren\Core\Core\Interfaces\HasRequiredArgs;
use Siren\Engagements\Core\Models\EngagementCalculationContext;

interface EngagementCalculationStrategy extends HasRequiredArgs
{
    /**
     * Stable identifier, stored in
     * wp_siren_program_engagement_types.calculationType and used as the
     * registry key. Lowercase camelCase, e.g. 'fixed', 'groupMembership'.
     */
    public static function getId(): string;

    public function getName(): string;

    public function getDescription(): string;

    /**
     * @return EngagementCalculationResult[]
     */
    public function calculate(EngagementCalculationContext $context): array;
}

The members map straight onto the registry and the picker. getId() is the stable id stored on the row and used as the registry key. getName() and getDescription() are the label and explanation shown in the calc picker. calculate() produces the results to emit, one Siren\Engagements\Core\Models\EngagementCalculationResult per credited collaborator. The caller loops the returned array and creates one engagement record per entry. The inherited getRequiredArgs(): string[] lists the arg keys your strategy needs, which the save handler validates are present.

calculate() is not handed its args directly. They are loaded from wp_siren_configs (config type programEngagementTypeArg, keyed by the context’s programEngagementTypeId) and read inside the strategy, so the interface stays stable as per-strategy args grow.

Those arg values are written when the program’s engagement type is saved through the program edit endpoint. The save handler reads your getRequiredArgs() and rejects the save if any required arg is missing, so an operator (or a recipe’s customizable fields) has to supply every one. getRequiredArgs() lists names only, not types, so if your strategy needs typed or validated admin inputs beyond presence, that is yours to build.

The metric side is the mirror image. Implement Siren\Metrics\Core\Interfaces\MetricCalculationStrategy (also extends HasRequiredArgs) with the same members. There, getId() is stored in wp_siren_distributor_metric_types.calculationType, calculate(MetricCalculationContext $context) returns Siren\Metrics\Core\Models\MetricCalculationResult[], and args load from config type distributorMetricTypeArg keyed by distributorMetricTypeId.

The two Result classes take the same constructor, (int $collaboratorId, int $score), so a result built on one side ports unchanged. The context objects do not match field for field, though. An EngagementCalculationContext exposes program, triggeringCollaborator, opportunity, engagementType, and programEngagementTypeId, while a MetricCalculationContext exposes distributor, triggeringCollaborator, metricType, and distributorMetricTypeId. There is no opportunity or program on the metric side, so any part of calculate() that reads those (the example below reads opportunity and program) has to be re-derived from the distributor when you port a strategy across.

A strategy that needs layered walker steps (as the cascade calcs do) also implements Siren\Core\Core\Interfaces\RequiresWalkerCapabilities so the picker only offers it against a compatible group structure. Implementing that marker is the entire opt-in. A strategy that does not implement it declares no capability requirement, so the picker offers it for every program and distributor regardless of the bound group’s structure. See walker capabilities for how to write a capability-requiring calc and how the picker filters on it. This page covers registering a strategy. That page covers the capability contract a cascade strategy declares.

Built-in calculation strategies

Three calculation strategies ship today. Each is registered on both the engagement side and the metric side under the same id. Fixed ships in the base tier, and the two cascades ship in Pro.

Fixed

ID: 'fixed'
Tier: Core (Lite and up)

Credits a single recipient, the collaborator who triggered the engagement or metric, at a static configured value. It needs no collaborator group and ignores group structure. See Fixed.

Upline cascade

ID: 'upline'
Tier: Pro

Walks the bound collaborator group from the triggering collaborator toward the head of the chain or tree and credits each layer above them at its per-layer points. Requires a structure that provides the hasLayer capability. See Upline cascade.

Downline cascade

ID: 'downline'
Tier: Pro

Walks the bound collaborator group from the triggering collaborator toward the leaves and credits each layer below them at its per-layer points. Also requires hasLayer. See Downline cascade.

Creating a custom strategy

This engagement-side strategy credits the triggering collaborator at a configured base value, then adds a flat bonus on top once the program reaches a configured engagement-count milestone. It reads both args from wp_siren_configs inside calculate(), declares them in getRequiredArgs(), and emits one EngagementCalculationResult for the triggering collaborator. Every member maps onto the interface verified above.

namespace MyPlugin\Engagements;

use PHPNomad\Datastore\Exceptions\DatastoreErrorException;
use PHPNomad\Logger\Interfaces\LoggerStrategy;
use Siren\Configs\Core\Datastores\Config\Interfaces\ConfigDatastore;
use Siren\Engagements\Core\Datastores\Engagement\Interfaces\EngagementDatastore;
use Siren\Engagements\Core\Interfaces\EngagementCalculationStrategy;
use Siren\Engagements\Core\Models\EngagementCalculationContext;
use Siren\Engagements\Core\Models\EngagementCalculationResult;

class MilestoneBonusEngagementCalculation implements EngagementCalculationStrategy
{
    public function __construct(
        protected ConfigDatastore     $configs,
        protected EngagementDatastore $engagements,
        protected LoggerStrategy      $logger
    ) {}

    public static function getId(): string
    {
        return 'milestoneBonus';
    }

    public function getName(): string
    {
        return 'Milestone Bonus';
    }

    public function getDescription(): string
    {
        return 'Awards a base score, plus a flat bonus once the program passes an engagement-count milestone.';
    }

    public function getRequiredArgs(): array
    {
        return ['baseValue', 'milestone', 'bonusValue'];
    }

    public function calculate(EngagementCalculationContext $context): array
    {
        try {
            $baseValue  = (int) $this->configs->getConfigValue(
                'programEngagementTypeArg',
                (string) $context->programEngagementTypeId,
                'baseValue',
                0
            );
            $milestone  = (int) $this->configs->getConfigValue(
                'programEngagementTypeArg',
                (string) $context->programEngagementTypeId,
                'milestone',
                0
            );
            $bonusValue = (int) $this->configs->getConfigValue(
                'programEngagementTypeArg',
                (string) $context->programEngagementTypeId,
                'bonusValue',
                0
            );
        } catch (DatastoreErrorException $e) {
            $this->logger->logException($e);
            return [];
        }

        $count = $this->engagements->getActiveEngagementCount(
            $context->opportunity->getId(),
            $context->program->getId()
        );

        $score = $count >= $milestone
            ? $baseValue + $bonusValue
            : $baseValue;

        return [new EngagementCalculationResult(
            $context->triggeringCollaborator->getId(),
            $score
        )];
    }
}

calculate() returns one result per credited collaborator. The trigger service loops that array and creates one engagement record per entry, so a strategy that credits a chain returns one result per layer rather than mutating any shared state. Here the strategy only ever credits the triggering collaborator, so it returns a single-element array. Returning an empty array credits no one, which is the fail-closed path. Catch your own errors inside calculate() and return [] rather than letting an exception escape, the way the example handles its datastore error and the built-in strategies do.

Register it

Write a listener that guards the event type and calls addCalculationStrategy() with your class:

namespace MyPlugin\Listeners;

use PHPNomad\Events\Interfaces\CanHandle;
use PHPNomad\Events\Interfaces\Event;
use Siren\Engagements\Core\Events\EngagementCalculationRegistryInitiated;
use MyPlugin\Engagements\MilestoneBonusEngagementCalculation;

class RegisterMyEngagementCalculations implements CanHandle
{
    public function handle(Event $event): void
    {
        if (!$event instanceof EngagementCalculationRegistryInitiated) {
            return;
        }

        $event->addCalculationStrategy(MilestoneBonusEngagementCalculation::class);
    }
}

Then wire the listener to the event in your Initializer:

public function getListeners(): array
{
    return [
        EngagementCalculationRegistryInitiated::class => RegisterMyEngagementCalculations::class,
    ];
}

The metric side is identical with MetricCalculationRegistryInitiated and a MetricCalculationStrategy implementation. Swap the class names and the result type and the rest of the shape holds.

How the first party registers the cascade strategies

Pro adds the upline and downline cascade strategies through this exact seam, with no changes to the lower tiers. On the engagement side, Siren\Pro\Core\Engagements\Listeners\RegisterProEngagementCalculations guards instanceof EngagementCalculationRegistryInitiated then registers both cascade calcs:

public function handle(Event $event): void
{
    if (!$event instanceof EngagementCalculationRegistryInitiated) {
        return;
    }

    $event->addCalculationStrategy(UplineCascadeEngagementCalculation::class);
    $event->addCalculationStrategy(DownlineCascadeEngagementCalculation::class);
}

The metric side mirrors this in Siren\Pro\Core\Metrics\Listeners\RegisterProMetricCalculations, which guards instanceof MetricCalculationRegistryInitiated and registers UplineCascadeMetricCalculation::class and DownlineCascadeMetricCalculation::class.

Both listeners are wired in Siren\Pro\Core\Initializer::getListeners():

EngagementCalculationRegistryInitiated::class => RegisterProEngagementCalculations::class,
MetricCalculationRegistryInitiated::class    => RegisterProMetricCalculations::class,

The engagement and metric cascade strategies share the id upline (and downline), but they live in separate registries, so there is no collision. Each pair of cascade calcs delegates its calculate() to a shared cascade service and differs only in the walker-selector closure it passes (upline or downline). They also implement RequiresWalkerCapabilities, returning [WalkerCapability::HAS_LAYER], which is what keeps them out of a flat-bound program or distributor’s picker.

A third-party strategy registers the same way the first party does. The seam is the same at every tier.

How calculation strategies fit the pipeline

When an engagement trigger fires, the strategy is the step that decides who gets credited and at what score:

  1. A trigger fires for a program engagement type, and the trigger service builds an EngagementCalculationContext carrying the program, the triggering collaborator, the opportunity, the engagement type, and the programEngagementTypeId
  2. The service reads the engagement type’s calculationType and resolves the matching strategy from the calculation registry by that id
  3. The strategy loads its own args from wp_siren_configs (config type programEngagementTypeArg, keyed by the context’s programEngagementTypeId) inside calculate()
  4. calculate() returns an EngagementCalculationResult[], one entry per credited collaborator with that collaborator’s id and score
  5. The trigger service loops the returned array and creates one engagement record per result, all sharing the same program and opportunity
  6. Those engagement records become the score data later steps read, including the resolvers and group sorters that attribute and distribute rewards downstream

The metric side mirrors this exactly, swapping MetricCalculationContext, distributorMetricTypeArg, distributorMetricTypeId, and MetricCalculationResult[]. The strategy decides credit and score. It does not create the records itself, which keeps the contract small and the same at every tier.