Siren

Custom Group Sorters

Implementing program group sorting strategies for multi-program attribution priority.

Last updated: April 6, 2026

Custom Group Sorters

When a business runs multiple programs, a single conversion could qualify for rewards under more than one program. A standard affiliate program and a premium partner program are a common example. Program groups solve this by grouping related programs together and using a sorter strategy to determine which program takes priority. Group sorters are the mechanism that decides attribution order when programs compete.

The Problem They Solve

Consider an e-commerce store with two programs in the same program group:

A standard affiliates program paying 10% commission and a premium partners program paying 20% commission.

A customer clicks Affiliate A’s link, then later clicks Partner B’s link, and then makes a purchase. Both programs have active engagements for this opportunity. The program group’s sorter determines which program gets to process the conversion first (and in many configurations, exclusively).

What interface do group sorters implement?

namespace Siren\ProgramGroups\Core\Interfaces;

interface ProgramSorterStrategy
{
    /**
     * Sorts the provided programs in the order they should run.
     *
     * @param array $programs Array of Program models in the group.
     * @param int $opportunityId The opportunity being converted.
     * @return array Programs sorted by priority (first = highest priority).
     */
    public function sortPrograms(array $programs, int $opportunityId): array;

    /**
     * Human-readable name for UI display and selection.
     */
    public function getName(): string;

    /**
     * Brief description of the sorting behavior.
     */
    public function getDescription(): string;

    /**
     * Unique identifier in camelCase format.
     */
    public static function getId(): string;
}

How does program sorting work?

The method receives all programs in the group and the opportunity ID. It must return the same programs in priority order. The first program in the returned array gets first claim on the conversion. Programs that should not receive credit can be excluded from the returned array entirely.

The opportunity ID is essential because the sort decision typically depends on engagement data. The sorter needs to know which collaborator engaged first, most recently, or most frequently for that specific opportunity.

Built-in Sorters

OldestBindingWins

ID: 'oldestBindingWins'

Sorts programs by the dateModified of their engagements in ascending order. The program whose engagement was created earliest takes priority. This rewards the first collaborator to begin tracking the customer.

Implementation detail: Queries the engagement datastore for engagements matching the opportunity and the programs in the group, sorted by dateModified ASC:

$engagements = $this->engagements->andWhere([
    ['column' => 'programId', 'operator' => 'IN', 'value' => Arr::pluck($programs, 'id')],
    ['column' => 'opportunityId', 'operator' => '=', 'value' => $opportunityId]
], null, null, 'dateModified');

NewestBindingWins

ID: 'newestBindingWins'

Sorts programs by the dateModified of their engagements in descending order. The program whose engagement was most recently created or updated takes priority. This rewards the most recent collaborator interaction. It is the “last touch” attribution model.

Implementation detail: Same query as OldestBindingWins but sorted DESC:

$engagements = $this->engagements->andWhere([
    ['column' => 'programId', 'operator' => 'IN', 'value' => Arr::pluck($programs, 'id')],
    ['column' => 'opportunityId', 'operator' => '=', 'value' => $opportunityId]
], null, null, 'dateModified', 'DESC');

Registration Pattern

Group sorters are registered by listening for GroupSorterStorageRegistryInitiated and calling addSorter():

namespace Siren\Collaborators\Service\Listeners;

use PHPNomad\Events\Interfaces\CanHandle;
use PHPNomad\Events\Interfaces\Event;
use Siren\Collaborators\Core\Events\GroupSorterStorageRegistryInitiated;
use Siren\ProgramGroups\Core\GroupSorters\NewestBindingWins;
use Siren\ProgramGroups\Core\GroupSorters\OldestBindingWins;

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

        $event->addSorter(NewestBindingWins::class);
        $event->addSorter(OldestBindingWins::class);
    }
}

The addSorter() method registers a lazy factory:

public function addSorter(string $sorterClass)
{
    $this->registry->set(
        $sorterClass::getId(),
        fn() => $this->provider->get($sorterClass)
    );
}

Wire the listener in your Initializer:

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

How do I access sorters at runtime?

The GroupSorterStorageService provides runtime access to sorters:

namespace Siren\Collaborators\Core\Services;

class GroupSorterStorageService
{
    /** Get a sorter by ID. */
    public function get(string $id): ?ProgramSorterStrategy;

    /** Get the sorter assigned to a specific program group. */
    public function getSorterForGroup(int $groupId): ?ProgramSorterStrategy;

    /** Get all registered sorters. */
    public function getSorters(): array;
}

The getSorterForGroup() method looks up the program group record, reads its sorter field (which stores the sorter ID string), and resolves it from the registry.

Creating a Custom Group Sorter

When to Create One

Create a custom sorter when the built-in “oldest” and “newest” engagement models do not match your attribution requirements. You might want the program with the highest engagement score to win, or you might want higher-tier programs to always beat lower-tier ones, or you might want to sort by historical revenue contribution, or even randomly select a program for A/B testing.

Example: Highest Score Wins

This sorter gives priority to the program whose engagement has the highest score. That means the collaborator who generated the most engagement activity for that opportunity wins:

namespace MyPlugin\GroupSorters;

use PHPNomad\Utils\Helpers\Arr;
use Siren\Engagements\Core\Datastores\Engagement\Interfaces\EngagementDatastore;
use Siren\Engagements\Core\Models\Engagement;
use Siren\ProgramGroups\Core\Interfaces\ProgramSorterStrategy;
use Siren\Programs\Core\Models\Program;

class HighestScoreWins implements ProgramSorterStrategy
{
    public function __construct(
        protected EngagementDatastore $engagements
    ) {}

    public function sortPrograms(array $programs, int $opportunityId): array
    {
        // Fetch engagements for all programs in the group
        /** @var Engagement[] $engagements */
        $engagements = $this->engagements->andWhere([
            ['column' => 'programId', 'operator' => 'IN', 'value' => Arr::pluck($programs, 'id')],
            ['column' => 'opportunityId', 'operator' => '=', 'value' => $opportunityId]
        ]);

        // Sort engagements by score descending
        usort($engagements, fn(Engagement $a, Engagement $b) => $b->getScore() <=> $a->getScore());

        // Map back to programs in score order
        $result = [];
        foreach ($engagements as $engagement) {
            $program = Arr::find(
                $programs,
                fn(Program $p) => $p->getId() === $engagement->getProgramId()
            );
            if ($program) {
                $result[] = $program;
            }
        }

        return Arr::whereNotNull($result);
    }

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

    public function getName(): string
    {
        return 'Highest Engagement Score Wins';
    }

    public function getDescription(): string
    {
        return 'Selects the program whose collaborator has the highest engagement score.';
    }
}

Register It

namespace MyPlugin\Listeners;

use PHPNomad\Events\Interfaces\CanHandle;
use PHPNomad\Events\Interfaces\Event;
use Siren\Collaborators\Core\Events\GroupSorterStorageRegistryInitiated;
use MyPlugin\GroupSorters\HighestScoreWins;

class RegisterHighestScoreSorter implements CanHandle
{
    public function handle(Event $event): void
    {
        if ($event instanceof GroupSorterStorageRegistryInitiated) {
            $event->addSorter(HighestScoreWins::class);
        }
    }
}

Wire it in your Initializer:

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

Removing or Replacing a Built-in Sorter

Use deleteSorter() on the registry event:

public function handle(Event $event): void
{
    if ($event instanceof GroupSorterStorageRegistryInitiated) {
        $event->deleteSorter('oldestBindingWins');
        $event->addSorter(MyCustomSorter::class);
    }
}

How Group Sorters Fit the Pipeline

During conversion processing:

  1. A conversion event fires (e.g., ConversionInitialized)
  2. BuildConversions identifies all programs with active engagements for this opportunity
  3. For programs in a program group, the group’s sorter is resolved via GroupSorterStorageService::getSorterForGroup()
  4. The sorter’s sortPrograms() orders the competing programs by priority
  5. Typically only the first program in the sorted order processes the conversion. The others are skipped.
  6. This ensures a single conversion does not generate duplicate rewards across competing programs

The sorter does not decide whether a program wins or loses. It decides the order in which programs are evaluated. The conversion system uses that order to give priority, usually processing only the first program with a valid engagement.