Siren

Custom Eligibility Resolvers

Implementing ProgramEligibilityResolver and DistributorEligibilityResolver to control which programs and distributors a collaborator can earn from.

Last updated: June 7, 2026

Eligibility resolvers decide which programs and distributors a collaborator is allowed to earn from. Before attribution credits a collaborator for a conversion, the eligibility service asks every registered resolver which candidate programs (or distributors) that collaborator qualifies for. A resolver is a source of eligibility. Direct collaborator-to-program bindings are one source, membership in a bound collaborator group is another, and a custom resolver can add a third.

There are two parallel seams: one for programs (engagement side) and one for distributors (metric side). They mirror each other, so anything described for the program side has an identical distributor counterpart.

How eligibility is resolved

The eligibility service unions the results of every registered resolver. Each resolver returns only the program ids it affirms are eligible. A resolver does not have to be exhaustive or exclusive, and it does not need to know about the other resolvers. The caller dedupes the combined set, so two resolvers affirming the same program is harmless and order across resolvers is not significant.

This union model is why a program can be eligible through more than one path at once. The Core direct-binding resolver and the Plus group-bound resolver both run, and a program bound directly to a collaborator stays eligible even if that collaborator is also in a group that does not include the program. Neither path masks the other.

Because the service only ever unions affirmations, eligibility is additive. A resolver can grant eligibility, never restrict it. There is no deny vote: a resolver that returns an empty set for a collaborator does not remove eligibility the other resolvers granted, it simply adds nothing. So a resolver is the wrong tool for excluding someone, a suspended collaborator for example. The only way to narrow eligibility is to remove a built-in resolver entirely (see below), which is global, not per-collaborator.

Affirming a program does two things, not one. It authorizes the collaborator to earn from that program, and through resolveAllEligibleProgramIds() it also surfaces the program in the collaborator’s REST programs list. Do not affirm a program you do not want the collaborator to see, not just earn from. A resolver that affirms an internal or unlaunched program for everyone exposes that program’s existence over the partner API.

If a resolver throws DatastoreErrorException, the whole eligibility check fails closed. The provider does not catch per resolver, so one resolver’s throw drops every resolver’s result for that check, and the attribution caller treats the failure as no eligibility, crediting no one for that conversion and logging the error. So catch recoverable errors inside your resolver and return [] (affirm nothing) if you want the other resolvers to still grant eligibility. Throw only when you genuinely want the entire check to fail closed.

What interface do program eligibility resolvers implement?

A program eligibility resolver implements Siren\Engagements\Core\Interfaces\ProgramEligibilityResolver:

namespace Siren\Engagements\Core\Interfaces;

use PHPNomad\Datastore\Exceptions\DatastoreErrorException;

interface ProgramEligibilityResolver
{
    /**
     * Filter variant. Returns the subset of $candidateProgramIds the
     * collaborator is eligible to earn from according to this resolver.
     *
     * @param int $collaboratorId
     * @param int[] $candidateProgramIds Program ids the caller is asking about.
     * @return int[] Order is not significant. Caller dedupes.
     *
     * @throws DatastoreErrorException
     */
    public function resolveEligibleProgramIds(int $collaboratorId, array $candidateProgramIds): array;

    /**
     * Unfiltered variant. Returns every program id this resolver affirms
     * the collaborator is eligible for, with no candidate filter applied.
     *
     * @return int[] Order is not significant. Caller dedupes.
     *
     * @throws DatastoreErrorException
     */
    public function resolveAllEligibleProgramIds(int $collaboratorId): array;
}

The two methods serve different callers. resolveEligibleProgramIds() is the filter variant. Attribution chokepoints already know which candidate programs they care about (for example, programs that have a particular engagement type configured), so they pass that candidate set and get back the eligible subset. resolveAllEligibleProgramIds() is the unfiltered variant, used by reverse-direction lookups that need every program a collaborator is associated with, such as the Collaborator’s programs REST field.

Keep the two methods consistent. resolveEligibleProgramIds($c, $candidates) should return exactly resolveAllEligibleProgramIds($c) intersected with $candidates. If they drift, attribution (which uses the filter variant) and the collaborator’s REST programs field (which uses the unfiltered variant) disagree, so a collaborator can be shown a program they never earn on, or earn on one they cannot see. Implement one in terms of the other where you can. The filter variant runs on every attribution, so keep it cheap and scope any query to the candidateProgramIds you are handed rather than fetching the collaborator’s whole eligibility each time.

What interface do distributor eligibility resolvers implement?

The distributor side is Siren\Metrics\Core\Interfaces\DistributorEligibilityResolver. It is a mirror of the program interface with the same union semantics:

namespace Siren\Metrics\Core\Interfaces;

use PHPNomad\Datastore\Exceptions\DatastoreErrorException;

interface DistributorEligibilityResolver
{
    /**
     * @param int $collaboratorId
     * @param int[] $candidateDistributorIds
     * @return int[]
     *
     * @throws DatastoreErrorException
     */
    public function resolveEligibleDistributorIds(int $collaboratorId, array $candidateDistributorIds): array;

    /**
     * @return int[]
     *
     * @throws DatastoreErrorException
     */
    public function resolveAllEligibleDistributorIds(int $collaboratorId): array;
}

Built-in Resolvers

Siren ships four eligibility resolvers, two on the program side and two on the distributor side. The provider service runs all registered resolvers and unions their results, so a collaborator is eligible whenever any resolver says so. The Plus group-bound resolvers do not replace the Core direct-binding ones, they widen eligibility alongside them. Resolvers are keyed by class-string rather than an id (see below), so each is identified by its fully qualified class.

DirectBindingProgramEligibilityResolver

Class: Siren\Engagements\Core\Services\DirectBindingProgramEligibilityResolver
Tier: Core (Lite and up)

A collaborator is eligible for a program when they have a direct row in the collaborators_programs junction, the binding written when you add a collaborator to a program directly. This is the historical default and the only eligibility path on Lite and Essentials.

DirectBindingDistributorEligibilityResolver

Class: Siren\Metrics\Core\Services\DirectBindingDistributorEligibilityResolver
Tier: Core (Lite and up)

The distributor counterpart. A collaborator is eligible for a distributor when they have a direct row in the collaboratorsDistributors junction.

GroupBoundProgramEligibilityResolver

Class: Siren\Plus\Core\Groups\Services\GroupBoundProgramEligibilityResolver
Tier: Plus

A collaborator is eligible for a program when the program is bound to a collaborator group the collaborator belongs to. This is what lets binding a group gate who can earn. The query detail is in How a bound group gates eligibility below.

GroupBoundDistributorEligibilityResolver

Class: Siren\Plus\Core\Groups\Services\GroupBoundDistributorEligibilityResolver
Tier: Plus

The distributor counterpart, with identical mechanics against a distributor’s collaborator-group binding.

The registry events

Resolvers are registered by listening for one of two registry-initiated events. Each is broadcast once, on first use of its eligibility provider service. Wire your listener through getListeners() so it runs during initialization, before that first use. A listener attached later misses the one-time broadcast and your resolver never runs, with no error. New to Siren’s event system? See the events introduction and listeners. Listeners register their resolver class via addResolver(), and the entry is stored as a lazy factory so the resolver is only instantiated when the provider actually iterates.

The program-side event is Siren\Engagements\Core\Events\ProgramEligibilityResolverRegistryInitiated:

Event ID: 'program_eligibility_resolver_registry_initiated'

The distributor-side event is Siren\Metrics\Core\Events\DistributorEligibilityResolverRegistryInitiated:

Event ID: 'distributor_eligibility_resolver_registry_initiated'

Both expose the same two methods:

public function addResolver(string $resolverClass): void;
public function deleteResolver(string $resolverClass): void;

These events key by class-string, not getId()

This is the one place where the eligibility seam differs from the other registry seams in Siren. The incentive resolver, group sorter, and calculation-strategy registries all key entries by the strategy’s getId() return value. The eligibility registry keys entries by the class-string itself:

public function addResolver(string $resolverClass): void
{
    $this->registry->set($resolverClass, fn() => $this->provider->get($resolverClass));
}

There is no getId() call here. The eligibility resolver interfaces do not declare getId(), getName(), or getDescription() at all, because resolvers are never selected in the UI. They run as a set. The practical consequence is that registering the same $resolverClass twice replaces the prior factory (last writer wins), and deleteResolver() takes the resolver class-string rather than an id string. Two different classes can never collide, so each tier’s resolver coexists with the others.

How a bound group gates eligibility

The group-bound resolvers are how binding a collaborator group to a program controls who can earn from it. Siren\Plus\Core\Groups\Services\GroupBoundProgramEligibilityResolver implements ProgramEligibilityResolver with this rule: a collaborator is eligible for a program if the program is bound to a collaborator group the collaborator is a member of.

Bindings are stored as wp_siren_configs rows keyed (type='program', subtype=<programId>, configKey='collaboratorGroupId', value=<groupId>). To resolve eligibility, the resolver first reads the collaborator’s group ids from members->getGroupsForCollaborator(), then queries the configs for binding rows whose value is in those group ids:

$bindings = $this->configs->andWhere([
    ['column' => 'type',      'operator' => '=',  'value' => 'program'],
    ['column' => 'configKey', 'operator' => '=',  'value' => 'collaboratorGroupId'],
    ['column' => 'value',     'operator' => 'IN', 'value' => $groupIds],
    ['column' => 'subtype',   'operator' => 'IN', 'value' => $candidateSubtypes],
]);

The matching subtype values, cast to int, are the eligible program ids. The filter variant constrains subtype to the candidate set so it never overfetches. The unfiltered variant runs the same query without the subtype constraint. If the collaborator has no memberships, or the candidate set is empty, the resolver returns an empty array.

The distributor counterpart, Siren\Plus\Core\Groups\Services\GroupBoundDistributorEligibilityResolver, implements DistributorEligibilityResolver with identical mechanics against type='distributor'.

These group-bound resolvers run in addition to the Core direct-binding resolvers, Siren\Engagements\Core\Services\DirectBindingProgramEligibilityResolver and Siren\Metrics\Core\Services\DirectBindingDistributorEligibilityResolver. The provider unions both, so a program can be eligible via a direct binding or via group membership, and neither path masks the other.

Registration pattern

Register a resolver by listening for the registry event and calling addResolver() with your class. The first-party Plus listeners follow this shape:

namespace Siren\Plus\Core\Groups\Listeners;

use PHPNomad\Events\Interfaces\CanHandle;
use PHPNomad\Events\Interfaces\Event;
use Siren\Engagements\Core\Events\ProgramEligibilityResolverRegistryInitiated;
use Siren\Plus\Core\Groups\Services\GroupBoundProgramEligibilityResolver;

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

        $event->addResolver(GroupBoundProgramEligibilityResolver::class);
    }
}

Wire the listener in your Initializer:

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

The distributor side is wired the same way against DistributorEligibilityResolverRegistryInitiated.

Creating a custom resolver

A custom resolver adds a new source of eligibility. Because results are unioned, your resolver only needs to affirm the program ids it knows about. It never has to reason about programs other resolvers handle. The example below makes every collaborator eligible for a single always-on program, regardless of binding. It is deliberately broad to show the shape, but in practice you affirm narrowly, since affirming both authorizes earning and surfaces the program to the collaborator over REST:

namespace MyPlugin\Eligibility;

use Siren\Engagements\Core\Interfaces\ProgramEligibilityResolver;

class HouseProgramEligibilityResolver implements ProgramEligibilityResolver
{
    private const HOUSE_PROGRAM_ID = 42;

    public function resolveEligibleProgramIds(int $collaboratorId, array $candidateProgramIds): array
    {
        return in_array(self::HOUSE_PROGRAM_ID, $candidateProgramIds, true)
            ? [self::HOUSE_PROGRAM_ID]
            : [];
    }

    public function resolveAllEligibleProgramIds(int $collaboratorId): array
    {
        return [self::HOUSE_PROGRAM_ID];
    }
}

Register it

namespace MyPlugin\Listeners;

use PHPNomad\Events\Interfaces\CanHandle;
use PHPNomad\Events\Interfaces\Event;
use Siren\Engagements\Core\Events\ProgramEligibilityResolverRegistryInitiated;
use MyPlugin\Eligibility\HouseProgramEligibilityResolver;

class RegisterHouseEligibilityResolver implements CanHandle
{
    public function handle(Event $event): void
    {
        if ($event instanceof ProgramEligibilityResolverRegistryInitiated) {
            $event->addResolver(HouseProgramEligibilityResolver::class);
        }
    }
}

Removing or replacing a resolver

Use deleteResolver() on the registry event. Because the registry keys by class-string, you pass the resolver class you want to remove, not an id string:

public function handle(Event $event): void
{
    if ($event instanceof ProgramEligibilityResolverRegistryInitiated) {
        $event->deleteResolver(GroupBoundProgramEligibilityResolver::class);
        $event->addResolver(MyReplacementResolver::class);
    }
}

Deleting a built-in is global. Removing DirectBindingProgramEligibilityResolver drops direct-binding eligibility for every collaborator and every program at once, not for one collaborator. Because resolvers cannot deny, deleting a built-in is the only lever that narrows eligibility, and it is a blunt one, so reach for it only when you mean to retire a whole eligibility path.

How eligibility fits the pipeline

  1. A conversion is being attributed and the system needs to know which programs a collaborator can earn from.
  2. The eligibility service iterates every registered ProgramEligibilityResolver and calls the appropriate method (filter or unfiltered).
  3. Each resolver returns the program ids it affirms. The Core direct-binding resolver returns directly bound programs, the Plus group-bound resolver returns programs bound to the collaborator’s groups, and any custom resolver adds its own.
  4. The service unions and dedupes the combined set. That set is the collaborator’s eligibility.

The distributor side runs the same flow with DistributorEligibilityResolver implementations.