Custom collaborator group structures
Register your own structure resolver to expose a new shape of hierarchy that programs and distributors can bind to.
Requires Siren Plus
Last updated: June 3, 2026
A collaborator group’s structure decides who sits above and below whom inside the group. When a cascade calc needs to pay an upline or downline, it asks the group’s structure for a walker and credits each step the walker yields. The cascade engine does not know what shape the group is, whether flat, linear chain, parent-child, weighted graph, or anything else. It just consumes walker steps.
That seam is what you extend here. Registering a new structure resolver introduces a new shape of hierarchy without touching the cascade engine, the picker, or any of the first-party calc strategies. Plus ships the flat resolver. Pro ships linear-chain and parent-child. Your integration can ship something else through the same event.
The interface
A structure resolver is a small object that knows its own id, knows how to describe itself in the admin UI, declares what walker capabilities it provides, and knows how to turn a group plus its members into a resolved structure.
namespace Siren\Plus\Core\Groups\Structure\Interfaces;
use Siren\Plus\Core\Groups\Models\CollaboratorGroup;
interface CollaboratorGroupStructureResolver
{
public static function getId(): string;
public function getName(): string;
public function getDescription(): string;
public function resolve(
CollaboratorGroup $group,
array $members
): CollaboratorGroupStructure;
}
getId() returns the string stored on the CollaboratorGroup record in the structure column. The first-party values are flat, linearChain, and parentChild. Pick something camelCase and stable. The id is a persisted reference, so once a group is saved with it, renaming or unregistering the id leaves that group unable to resolve its structure, and a cascade bound to it fails closed and credits no one.
getName() and getDescription() feed the structure dropdown on the collaborator-group edit screen. The name is the option label, and the description sits underneath so operators can pick the right structure for their use case.
resolve() is the only real work. It receives the group record and the full list of CollaboratorGroupMember rows that belong to it. Its job is to precompute anything the walker side will need (parent maps, position indices, adjacency lists) and hand back a CollaboratorGroupStructure. The resolver runs once per request the structure is needed. The returned object handles every walker call from there.
Structure resolvers that participate in cascades also implement HasProvidedWalkerCapabilities (covered below).
The structure interface
The resolved structure is the object the cascade engine actually talks to.
namespace Siren\Plus\Core\Groups\Structure\Interfaces;
use Novatorius\Iterator\Interfaces\CanIterate;
interface CollaboratorGroupStructure
{
public function getUplineWalker(int $collaboratorId): CanIterate;
public function getDownlineWalker(int $collaboratorId): CanIterate;
public function getAllMembersWalker(): CanIterate;
public function contains(int $collaboratorId): bool;
}
Every structure exposes the same four operations. getUplineWalker() and getDownlineWalker() return walker-step value objects in cascade order, layer 1 first, then layer 2, then layer 3. getAllMembersWalker() yields the raw CollaboratorGroupMember rows for non-cascade consumers like the admin UI or a directory listing. contains() answers whether a given collaborator is a member of this group at all.
When a direction doesn’t apply, for example asking a flat group for its upline, the returned walker yields nothing. That is the honest answer, not a no-op: a flat group has no upline. Calc strategies handle empty walkers gracefully.
The upline and downline walkers yield walker-step value objects (Pro’s HierarchicalWalkerStep is the first-party example) that implement WalkerStep plus capability marker interfaces. The all-members walker yields raw member models. Don’t mix the two.
Your walker constructs each step and stamps it with its layer, the 1-indexed distance from the trigger. The cascade engine reads that layer off the step with getLayer(); it does not compute it for you, so a walker that yields steps in the wrong order or with the wrong layer mis-credits the cascade. The WalkerStep and HasLayer interfaces and the concrete HierarchicalWalkerStep all live in the Pro tier, so a structure that yields layered, cascade-compatible steps builds on Pro and can reuse HierarchicalWalkerStep directly, for example new HierarchicalWalkerStep($member, $layer). A structure that declares no capabilities, like a flat one, yields empty directional walkers and needs none of this.
Walker capabilities
A walker step is just a small value object that wraps a CollaboratorGroupMember and exposes whatever the structure was able to figure out at resolve time. The pieces of data it carries are called capabilities, and each capability is declared by implementing a marker interface: HasLayer, plus whatever new ones your structure type introduces.
getProvidedWalkerCapabilities() is the resolver’s way of advertising, ahead of time, which capabilities its walker steps will carry:
public function getProvidedWalkerCapabilities(): array
{
return ['hasLayer'];
}
The string hasLayer is the capability id. The first-party code references it through the WalkerCapability::HAS_LAYER constant, which holds that same string, so prefer the constant over a raw literal where you can reach it to avoid a silent typo. A mistyped capability id does not error, it just fails to match, and your calc quietly never appears in the picker.
The picker on the program and distributor edit screens uses this list to filter the calc dropdown. A calc that requires hasLayer (the first-party upline and downline cascades both do) is hidden when the bound group’s structure resolver doesn’t declare it. That’s how Siren keeps operators from picking calcs that can’t possibly run against the group they bound.
The first-party hasLayer capability says walker steps know which layer they’re at, meaning distance from the trigger, 1-indexed. If your structure has a meaningful concept of weight, distance, branch position, or anything else a custom calc might want to read, declare a new capability id here and have your walker steps implement the matching marker interface. See walker capabilities for the full story.
A flat structure declares no capabilities. Its directional walkers are always empty, so there’s nothing to advertise.
Writing a custom resolver
This is the shape of a real resolver. The first-party flat resolver is the simplest example in the codebase, with the same structure and just less to do inside resolve():
namespace MyPlugin\Groups\Structure;
use Siren\Core\Core\Interfaces\HasProvidedWalkerCapabilities;
use Siren\Plus\Core\Groups\Models\CollaboratorGroup;
use Siren\Plus\Core\Groups\Structure\Interfaces\CollaboratorGroupStructure;
use Siren\Plus\Core\Groups\Structure\Interfaces\CollaboratorGroupStructureResolver;
use Siren\Translations\Core\Services\TranslationService;
final class HubAndSpokeStructureResolver implements
CollaboratorGroupStructureResolver,
HasProvidedWalkerCapabilities
{
public function __construct(
protected TranslationService $translator
) {}
public static function getId(): string
{
return 'hubAndSpoke';
}
public function getName(): string
{
return $this->translator->translate('Hub and spoke');
}
public function getDescription(): string
{
return $this->translator->translate(
'One hub collaborator at the center; all other members are spokes that report directly to the hub.'
);
}
public function resolve(
CollaboratorGroup $group,
array $members
): CollaboratorGroupStructure {
// Precompute the hub + spoke index from metadata so the
// returned structure can answer walker calls without re-scanning.
return new HubAndSpokeStructure($members);
}
public function getProvidedWalkerCapabilities(): array
{
return ['hasLayer'];
}
}
The matching HubAndSpokeStructure class implements CollaboratorGroupStructure. Its upline walker for a spoke yields one step pointing at the hub at layer 1. Its downline walker for the hub yields every spoke at layer 1. For any other request the walker yields nothing. Constructor injection works the same as anywhere else in Siren: declare the dependencies you need and the container wires them.
Use whatever shape makes sense for your structure. Trees, graphs, geographic regions, weighted relationships: the cascade engine doesn’t care, as long as the walkers it receives yield steps in cascade order.
Built-in structures
Three structure resolvers ship today. Plus provides the flat resolver. Pro provides the linear-chain and parent-child resolvers. Each appears in the structure dropdown on the collaborator-group edit screen and is bound to a group by storing its id in the structure column.
Flat
ID: 'flat'
Tier: Plus
A flat collection of collaborators with no internal hierarchy. Every member is a peer, so the directional walkers always yield nothing and the resolver advertises no capabilities. See Flat.
Linear chain
ID: 'linearChain'
Tier: Pro
Members are ordered by a position, where each position has at most one upline (the lower-position member) and one downline (the higher-position member). It provides the hasLayer capability so cascade calcs can run against it. See Linear chain.
Parent-child
ID: 'parentChild'
Tier: Pro
Members are arranged as a tree where each member can have one parent and any number of children. Upline walks parent to parent up to the root, and downline walks every descendant, crediting all peers at the same depth at the same per-layer rate. It provides the hasLayer capability. See Parent-child.
Registering the resolver
Resolvers register through the CollaboratorGroupStructureResolverRegistryInitiated event. The event fires once on first read of the resolver service. Wire your listener through getListeners() so it runs during initialization, before that first read. A listener attached later misses the one-time broadcast and your structure never appears in the dropdown, with no error. New to Siren’s event system? See the events introduction and listeners. Listeners call addStrategy() with a class string, and the registry stores a lazy factory, so your resolver is only instantiated when something actually asks for it.
namespace MyPlugin\Listeners;
use PHPNomad\Events\Interfaces\CanHandle;
use PHPNomad\Events\Interfaces\Event;
use Siren\Plus\Core\Groups\Structure\Events\CollaboratorGroupStructureResolverRegistryInitiated;
use MyPlugin\Groups\Structure\HubAndSpokeStructureResolver;
class RegisterHubAndSpokeStructure implements CanHandle
{
public function handle(Event $event): void
{
if (!$event instanceof CollaboratorGroupStructureResolverRegistryInitiated) {
return;
}
$event->addStrategy(HubAndSpokeStructureResolver::class);
}
}
Wire the listener in your Initializer:
public function getListeners(): array
{
return [
CollaboratorGroupStructureResolverRegistryInitiated::class
=> RegisterHubAndSpokeStructure::class,
];
}
To remove or replace a built-in resolver, call deleteStrategy() on the same event before adding your replacement:
$event->deleteStrategy('flat');
$event->addStrategy(MyFlatReplacement::class);
For this to be safe, your replacement must return the same getId() (here flat), because groups are already saved with that id. Deleting a built-in id that existing groups use without restoring it under the same id leaves those groups unable to resolve their structure, so their cascades fail closed.
What appears in the admin UI
Once registered, your resolver shows up automatically. The collaborator-group edit screen lists every registered resolver in the structure dropdown using getName() for the option label and getDescription() for the helper text underneath. The picker on the program and distributor edit screens reads getProvidedWalkerCapabilities() to filter the calc dropdown. Calcs whose required capabilities your resolver doesn’t provide are hidden when a group using your structure is bound.
GET /collaborator-groups/structures returns the full registry as JSON, including each resolver’s id, name, description, and provided capabilities, so any custom admin surface or external tooling can render the same picker.
That’s the whole seam. Implement the two interfaces, declare your capabilities, register through the event. The cascade engine, the picker, and the admin UI pick up your structure with no further wiring. For a deeper look at how walker capabilities flow through the picker and what it takes to ship a custom calc that requires a custom capability, see walker capabilities.
How structures fit the cascade pipeline
During conversion processing:
- A cascade calc fires for the triggering collaborator (an upline or downline strategy bound to a program or distributor)
- The bound group’s structure is resolved from its
structureid into aCollaboratorGroupStructure - The calc asks the structure for an upline or downline walker, which produces walker steps in cascade order, layer 1 first
- The calc credits each layer the walker yields at its per-layer points, one result per step
The structure decides the shape of the hierarchy. The calc decides who gets credited and how much. The two stay decoupled through the walker steps the structure yields.