pandabear / mlm
Configurable MLM engine plugin for Panda Panel.
Requires
- php: ^8.2
- chocoalano/panel: ^0.5.7
- illuminate/database: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
Requires (Dev)
- laravel/pint: ^1.18
- orchestra/testbench: ^10.0|^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A configurable MLM engine for Panda Panel, part of the pandabear.asia ecosystem.
Status: stable — v1.0.0, released 2026-10-01. It provides the Panda Panel plugin and its technical configuration; the core domain — programs and their members; plans with versioning, a version lifecycle and versioned definitions — components and rules in a safe rule language, validated before use; qualification — one chosen rule evaluated for a member, with a complete trace; ranks — one chosen rank ladder evaluated for a member, the highest qualifying rank selected and every rank explained; the sponsor and placement genealogies, each readable as it stands or as it stood at any past moment, an explicit binary left/right overlay on placement, and an explicit matrix overlay of numbered slots up to a program's fixed width; an exact, immutable volume history with idempotent recording, explicit reversal and member totals; a financial ledger — member wallets and program system accounts, balanced single-currency transactions, idempotent posting, reversal and exact derived balances; a commission core — registered calculation strategies, audited snapshot-consistent calculation runs, a review lifecycle and posting through the ledger — with built-in direct-sponsor, depth-based, matrix and binary pairing strategies, composed into hybrid calculation batches, and commission periods that calculate, close, hold and release a program's commissions before posting, and payouts that reserve wallet funds through the ledger at approval, record the provider's settlement, refund failures and group requests into batches, fixed or proportional with explicit rounding, binary carry kept source by source, clawback of commissions whose source is later reversed, and binary reversal correction — undone pairs, restored carry and the exact financial share of each commission, before or after posting; and metrics — a registry, an engine and the built-in
member.volume,sponsor.network.volume,placement.network.volume,binary.left.volume,binary.right.volumeandmatrix.network.volume, network and leg volume read through the genealogy as it was when each activity happened. The suite runs on SQLite, MySQL and PostgreSQL, including real concurrent database sessions. The Panda Panel plugin gives operators the whole surface: programs, members and networks, the Plan Builder, a genealogy explorer, periods, commissions, the ledger and payouts. v1.0.0 establishes the stable public API: the surface listed in docs/public-api.md follows Semantic Versioning, so a breaking change there needs a new major version, while anything not listed is internal. What the release was verified on, and how, is in docs/production-readiness.md. See Known limitations for what is deliberately out of scope.
Requirements
- PHP ^8.2
- Laravel 12 or 13
- Panda Panel (
chocoalano/panel) ^0.5.7 - SQLite, MySQL or PostgreSQL
What has actually run, as distinct from what is declared:
| Full suite verified | Also checked | Not executed | |
|---|---|---|---|
| PHP | 8.2, 8.4 | clean installs on both | 8.3, 8.5 |
| Laravel | 12 (12.69), 13 (13.30 and 13.33) | clean installs on both | releases Composer refuses for security advisories |
| Panda Panel | 0.5.7, 0.5.8 | clean installs on both | — |
| MySQL | 9.6.0 | — | 8.x |
| PostgreSQL | 18.4 | — | 15 |
| SQLite | 3.53 | — | — |
The v1.0.0 release gate ran the full suite on SQLite 3.53.4, MySQL 9.6.0 and PostgreSQL 18.4. MySQL 8 and PostgreSQL 15 are intended compatibility floors but were not run in the release gate: run composer test:mysql or composer test:pgsql against your server before relying on them. The whole matrix, and how each cell was run, is in docs/production-readiness.md.
Installation
Install the package with Composer:
composer require pandabear/mlm:^1.0
What each release contains is in CHANGELOG.md.
Laravel's package discovery registers PandaBear\Mlm\PandaMlmServiceProvider automatically. Then create the tables:
php artisan migrate
Upgrading
Run php artisan migrate. Migrations only add tables and columns; an installation from v0.1.0 keeps its data, and nothing is backfilled into new tables. Once financial data exists, do not roll migrations back: restore a backup or fix forward with a new migration.
Panel registration
The plugin is never added to a panel automatically. Install it in the panel provider that should have it:
use PandaBear\Mlm\PandaMlmPlugin; $panel->plugins([ PandaMlmPlugin::make(), ]);
Confirm it is registered:
php artisan panel:plugins
Panda Panel
PandaMlmPlugin::make() registers the operational surface on the panel it is installed in, under one sidebar group, Network & Compensation (Jaringan & Kompensasi in Indonesian). The panel is an adapter (ADR-031): every change is one call to a domain service, every choice comes from a registry, an enum or a real record, and nothing is ever written, re-statused or deleted by the panel itself.
| Screen | Offers |
|---|---|
| MLM Overview | four counts: active programs, commission periods awaiting action, commissions available to post, payout requests awaiting action |
| Programs | New program; detail with members, plans and periods, links to every screen filtered to the program, Open system account, Configure matrix (width, once) |
| Members | New member; detail with the current network position and wallets; Assign sponsor, Place, Place in binary, Add binary position, Place in matrix, Add matrix position; links to the genealogy explorer, wallets, commissions and payouts |
| Genealogy explorer | one anchor's sponsor, placement, binary or matrix network, now or as of a moment, up to 5 levels and 500 members |
| Plans | New plan; detail with versions, New draft version, Clone version |
| Plan versions | the Plan Builder: overview, components, rules and a review of the whole definition; Add component, Change, Remove, Add rule while a draft; Validate, Publish, Activate, Archive, Clone to new draft |
| Commission periods | Open period; detail with runs and exact totals; Calculate, Finalize, Release |
| Calculation runs | list and detail: what each belongs to (period, hybrid batch or neither) and its commissions |
| Commissions | detail with trace, ledger transaction and adjustments; Mark pending, Approve, Cancel, Post |
| Wallets | list and detail: the exact balance read from the ledger, and the ledger postings |
| Payout requests | New payout request; Approve, Start processing, Settle, Mark failed, Cancel |
| Payout batches | New payout batch; detail with requests and exact totals; Add request while open; Seal, Start processing, Complete, Cancel batch |
Each action calls one service — ProgramManager, LedgerAccountManager, SponsorGenealogy, PlacementGenealogy, BinaryPlacementManager, MatrixNetworkManager, MatrixPlacementManager, PlanVersionLifecycle, PlanDefinitionEditor, PlanDefinitionCloner, the period services, CommissionLifecycle, CommissionPoster, PayoutManager or PayoutBatchManager — in its own transaction, and a refusal is shown to the operator with the service's reason. Nothing moves, removes or deletes a sponsor, placement, position, batch member or financial record, and nothing places a member automatically.
Plan Builder
A plan starts with no version. New draft version adds an empty draft; Clone version (or Clone to new draft on a version) copies a version's components and rules — never its runs, commissions, periods or network state. While the version is a draft, components are added and changed through PlanDefinitionEditor: the driver comes from PlanComponentDriverRegistry, a commission strategy from CommissionStrategyRegistry, and a built-in strategy shows exactly the parameters its parameter class names (levels as depth and amount rows); any other driver or strategy takes a JSON object, stored as data and never run. Rules are one group of conditions — all or any — each a metric from MetricRegistry, its parameters, one of the language's operators (!=, >, >=, <, <=, in, not_in, between) and decimal operands; a rule with nested groups is removed and re-added rather than changed. Validate runs PlanDefinitionValidator through the lifecycle and shows what it refuses; a validated version is never edited again. Publish and Activate are the lifecycle's own steps — activating supersedes the plan's previous active version in the same step.
Networks and the genealogy explorer
Sponsor and generic placement are separate networks; binary and matrix are explicit overlays over generic placement. Place in binary and Place in matrix create the placement and the position together, naming the parent and the side or slot; Add binary position and Add matrix position give a member's existing placement one. A matrix slot is a whole number up to the program's width, set once with Configure matrix. Binary needs no setup.
The explorer is read-only. It counts before it reads and shows the deepest level that stays within 500 members, loading the network through its genealogy reader — descendants() now, descendantsAt() as of a moment — and the direct links in one more query. It is drawn with the panel's own table: Panda Panel resolves Vue components from the application's tree, not a plugin's, so the hierarchy is carried by order and indentation.
Capabilities
Every screen and action asks a capability through Laravel's Gate — never a role. Grant them however the application grants abilities: Gate::define(), a Gate::before() hook, or a permission package whose can() answers for them.
use PandaBear\Mlm\Panel\MlmPermission; MlmPermission::all(); // every capability, for seeding a permission store MlmPermission::view(); // mlm.dashboard.view, mlm.programs.view, mlm.members.view, mlm.network.view, … mlm.payouts.view MlmPermission::operate(); // mlm.programs.operate, mlm.members.operate, mlm.network.operate, mlm.plans.operate, // mlm.periods.operate, mlm.commissions.operate, mlm.payouts.operate
view capabilities open lists, details and the explorer (mlm.ledger.view adds ledger postings and transaction references). Each operate capability runs its own area's actions and no other: mlm.network.operate the network writes and the matrix width, mlm.plans.operate the Plan Builder, mlm.commissions.operate commission review and posting, and so on. An operator needs both the view and the operate capability of a screen, and every action asks its capability again when it runs.
Words and icons
The surface speaks English and Indonesian and follows the application's locale; override any sentence under lang/vendor/mlm/{locale}/mlm.php. Nothing is published. Its icons are ones Panda Panel itself already declares, so php artisan panel:icons — which scans the application and the framework, not plugins — keeps every one of them in the registry.
Core domain
Database
The service provider registers the package migrations, so the tables are created by the application's own migrate command:
php artisan migrate
| Table | Holds |
|---|---|
mlm_programs |
programs: id, code, name |
mlm_members |
members: id, program_id, member_code, external_type, external_id, joined_at |
mlm_plans |
plans: id, program_id, code, name |
mlm_plan_versions |
plan versions: id, plan_id, version, status, and one timestamp per lifecycle step |
mlm_sponsor_edges |
direct sponsorships: id, member_id, sponsor_id, assigned_at |
mlm_genealogy_paths |
every ancestor/descendant pair of each tree — sponsor, placement, binary: tree_type, ancestor_id, descendant_id, depth, effective_from |
mlm_placement_edges |
direct placements: id, member_id, parent_id, placed_at |
mlm_volume_entries |
immutable volume history: program_id, member_id, type, quantity_millionths, source_type, source_id, idempotency_key, effective_at, reversal_of_id |
Primary keys are ULIDs. The tables and the models use the connection named by mlm.database.connection — the application's default when it is not set.
Program
A program is one independent MLM business — a distributor network, a reseller program — and the boundary everything else belongs to. One installation can run several. Its code is unique; its name is not.
use PandaBear\Mlm\Models\Program; $program = Program::create([ 'code' => 'MAIN', 'name' => 'Main Distributor Program', ]);
PandaBear\Mlm\Program\ProgramManager makes the same writes — create(), a member's join(), a plan's addPlan() — and refuses a code or external identity already taken with ConflictingProgramRecord instead of a database error. The panel creates through it.
Member
A member is a participant in exactly one program. It is not the application's user model. Members join through their program:
$member = $program->members()->create([ 'member_code' => 'MBR-000001', 'joined_at' => now(), ]);
program_id is not mass assignable, and a program cannot be deleted while it still has members.
Program-scoped membership
A member_code is unique within its program, and the same code may be used in another program. The package never picks a program from the request, the session or the signed-in user — the program is always passed explicitly.
External identity
A member can point at whatever the application calls its people, without the package depending on that model:
$program->members()->create([ 'member_code' => 'MBR-000002', 'external_type' => 'customer', // a label the application chooses 'external_id' => 'CUS-00192', // integer, UUID, ULID or business code 'joined_at' => now(), ]);
Both fields are optional, but they are set together or not at all. One external identity is at most one member per program, and may be a member of several programs.
Planning
Plan
A plan is the stable identity of a business plan inside a program. Its code is unique within the program. A plan holds no rules — everything that changes between revisions belongs to its versions.
$plan = $program->plans()->create([ 'code' => 'STANDARD', 'name' => 'Standard Plan', ]);
Plan versioning
A plan version is one numbered revision of a plan: 1, 2, 3, unique within the plan and allocated for you. Versions are created and moved only through PandaBear\Mlm\Planning\PlanVersionLifecycle, resolved from the container:
use PandaBear\Mlm\Planning\PlanVersionLifecycle; $lifecycle = app(PlanVersionLifecycle::class); $version = $lifecycle->draft($plan); // version 1, draft $lifecycle->markValidated($version); $lifecycle->publish($version); $lifecycle->activate($version); $plan->currentActiveVersion(); // version 1, or null when none is active
Plan version lifecycle
draft → validated → published → active → superseded → archived
- The lifecycle only moves forward, one step at a time. An operation out of order throws
InvalidPlanVersionTransition. - Activating a newer published version supersedes the active one in the same transaction, so a plan has at most one active version. An older version can never replace a newer active one. Superseding is not a separate operation.
- Only a superseded version can be archived.
- Each step records its moment once:
validated_at,published_at,activated_at,superseded_at,archived_at. - Only a draft is mutable. From
validatedon, a version is locked:isMutable()is false,assertMutable()throwsPlanVersionNotMutable, and it cannot be deleted. statuscannot be mass assigned, and a status or timestamp set directly on the model is refused on save. Raw query-builder writes bypass these guards, as they bypass every Eloquent rule.
Plan definition
A version's definition is its components, each with rules. A draft is edited; from validated on, the definition never changes.
- A component selects a driver by key: trusted code the application registers. The database stores the key, never a class name. The package ships two,
rank.ladder(see Rank) andcommission.strategy(see Calculation runs); applications register their own beside them. - A component's parameters are plain JSON data for its driver. Nothing in them is ever run.
- A rule is a tree in a small, safe language: groups (
all,any) of conditions on registered metrics, with the operators!=,>,>=,<,<=,in,not_inandbetween(both bounds included) and exact decimal operands. No expressions, formulas, SQL or code. - A rule may only name a metric that implements
PlanConfigurableMetric— the three built-ins do — so its parameters can be checked before anything is resolved.
use PandaBear\Mlm\Exceptions\InvalidPlanDefinition; use PandaBear\Mlm\Planning\PlanComponentDefinition; use PandaBear\Mlm\Planning\PlanComponentDriver; use PandaBear\Mlm\Planning\PlanComponentDriverRegistry; final class AcmeCriteriaDriver implements PlanComponentDriver { public function key(): string { return 'acme.criteria'; } public function validate(PlanComponentDefinition $component): void { if ($component->rules === []) { throw InvalidPlanDefinition::input('rules', 'acme.criteria needs at least one rule.'); } } } // In a service provider's register(): $this->callAfterResolving(PlanComponentDriverRegistry::class, function (PlanComponentDriverRegistry $drivers): void { $drivers->register(new AcmeCriteriaDriver); });
use PandaBear\Mlm\Planning\PlanDefinitionCloner; use PandaBear\Mlm\Planning\PlanDefinitionEditor; use PandaBear\Mlm\Planning\Rules\MetricCondition; use PandaBear\Mlm\Planning\Rules\RuleDefinition; use PandaBear\Mlm\Planning\Rules\RuleGroup; $editor = app(PlanDefinitionEditor::class); $draft = $lifecycle->draft($plan); $entry = $editor->addComponent($draft, 'entry', 'acme.criteria', 'Entry criteria'); $editor->addRule($entry, 'active', 'Active member', RuleDefinition::all( MetricCondition::of('member.volume', ['type' => 'sales'], '>=', '100'), RuleGroup::any( MetricCondition::of('sponsor.network.volume', ['type' => 'sales', 'max_depth' => 3], '>=', '500'), MetricCondition::of('placement.network.volume', ['type' => 'sales'], 'between', '400', '900'), ), )); $lifecycle->markValidated($draft); // checks the whole definition, or throws InvalidPlanDefinition $next = app(PlanDefinitionCloner::class)->cloneToNewDraft($draft); // the next version, a draft holding a copy
PlanDefinitionEditoris the only way to change a definition. It adds, updates and removes components and rules, and works on drafts only — deciding from the stored, locked version, never from a model in memory. Changing a locked version throwsPlanVersionNotMutable.markValidated()checks the whole definition first: drivers registered and satisfied, rules well-formed, metrics registered, plan-configurable and given valid parameters. If anything fails, the version stays a draft and nothing changes. An empty definition is valid.- To change a validated, published or active definition, clone it into a new draft with
PlanDefinitionCloner, edit the draft and validate it. - Read a definition through
$version->components,$component->rulesand$rule->definition. Components and rules are read-only through Eloquent.
Rules are evaluated one at a time by the qualification engine below, and a rank ladder's rules by the rank engine after it. Commission is not implemented.
Qualification
The qualification engine evaluates one stored rule, chosen by you, for one member over an optional effective range [from, until):
use PandaBear\Mlm\Qualification\QualificationContext; use PandaBear\Mlm\Qualification\QualificationEngine; $decision = app(QualificationEngine::class)->evaluate( $rule, // a PlanRule of a validated version new QualificationContext( member: $member, from: $start, // optional, included until: $until, // optional, excluded ), ); $decision->qualified; // true or false $decision->toArray(); // identities, range and the full trace
- You select the rule. The engine never picks a plan, the active version, or a rule, and gives no meaning to several rules in one component.
- A draft's rule cannot be evaluated. A rule of any validated version — validated, published, active, superseded or archived — can, so past decisions can be reproduced.
- The member must belong to the rule's program. Rule, version and member are read from the database, not from the instances you pass.
- Every condition's metric is resolved through the metric engine with the same range, and compared exactly.
betweenincludes both bounds. Every condition is evaluated and appears in the trace, with its value, even when the result was already decided. qualifiedis false only when the rule was evaluated and does not hold. A draft, another program's member, a missing metric or a failing one throwsQualificationEvaluationExceptioninstead.- Results are not stored, and nothing is written. Commission is not implemented.
Rank
A rank ladder is a plan component with the built-in driver rank.ladder. Each of its rules is one rank: the rule's key is the rank's key, its name the rank's name, its position the rank's order — a larger position is a higher rank — and its definition what the rank requires. It is built with the editor, like any component:
use PandaBear\Mlm\Planning\Rules\MetricCondition; use PandaBear\Mlm\Planning\Rules\RuleDefinition; $ladder = $editor->addComponent($draft, key: 'career-ranks', driver: 'rank.ladder', name: 'Career Ranks', parameters: []); $editor->addRule($ladder, key: 'bronze', name: 'Bronze', position: 10, definition: RuleDefinition::all( MetricCondition::of('member.volume', ['type' => 'sales'], '>=', '100'), )); $editor->addRule($ladder, key: 'silver', name: 'Silver', position: 20, definition: RuleDefinition::all( MetricCondition::of('member.volume', ['type' => 'sales'], '>=', '100'), MetricCondition::of('sponsor.network.volume', ['type' => 'sales'], '>=', '1000'), )); $lifecycle->markValidated($draft);
- A ladder takes no parameters, needs at least one rank, and no two of its ranks may share a position; gaps are fine. A draft may break these while it is edited —
markValidated()refuses it. - There is no rank table, rank editor or rank cloner: ladders are stored, edited, validated and cloned as plan definitions.
The rank engine evaluates one stored ladder, chosen by you, for one member over an optional effective range [from, until):
use PandaBear\Mlm\Rank\RankContext; use PandaBear\Mlm\Rank\RankEngine; $decision = app(RankEngine::class)->evaluate( $ladder, // a rank.ladder PlanComponent of a validated version new RankContext( member: $member, from: $start, // optional, included until: $until, // optional, excluded ), ); $decision->selectedRank; // SelectedRank (key, name, position), or null $decision->toArray(); // identities, range, selected rank, and every rank with its trace
- You select the ladder. The engine never picks a plan, the active version or a ladder, and never merges two ladders. A component of another driver is refused.
- Every rank is qualified through the qualification engine, lowest position first, all with the same range — every one of them, whatever the others give — and each keeps its qualification trace.
- The selected rank is the qualifying rank with the highest position. Ranks are independent: a higher rank does not need the lower ones — write their requirements into it if it should.
- No qualifying rank gives
selectedRanknull: a decision, not an error. A draft ladder, another program's member, a broken ladder or a rank that cannot be evaluated throwsRankEvaluationExceptioninstead — never a lower rank or null. - A ladder of any validated version — superseded and archived included — can be evaluated, and gives the rank as that version defines it.
- A rank is derived, never stored: there is no current-rank field, rank history, promotion or demotion yet, and nothing is written.
Sponsor genealogy
The sponsor genealogy records who sponsored whom within a program. It is not placement — see Placement genealogy.
Direct sponsor
use PandaBear\Mlm\Genealogy\SponsorGenealogy; $genealogy = app(SponsorGenealogy::class); $genealogy->assignSponsor($bob, $alice); // Alice sponsored Bob $genealogy->directSponsor($bob); // Alice, or null for a root $genealogy->directMembers($alice); // everyone Alice sponsored directly
- A member has at most one sponsor, and a member without one is a root. A program may have many roots.
- A sponsor may sponsor any number of members.
- The member and its sponsor must be in the same program. A member cannot sponsor itself.
- A sponsor is assigned once. There is no reassignment or removal yet.
- A member that already sponsors others can still receive its first sponsor; its whole subtree is attached beneath that sponsor.
Ancestors and descendants
$genealogy->ancestors($diana); // Bob (depth 1), Alice (depth 2) $genealogy->descendants($alice); // nearest first $genealogy->descendants($alice, maxDepth: 1); // direct members only
Both return SponsorRelative objects — the member and its depth, the number of sponsorship steps between them — nearest first. The member itself is never included. Results stay within the member's program.
History
The queries above read the sponsor tree as it stands. Each has an …At() counterpart that reads it as it stood at a moment:
use Carbon\CarbonImmutable; $march = CarbonImmutable::parse('2026-03-01 00:00:00'); $genealogy->directSponsorAt($bob, $march); // null if Bob was not sponsored yet $genealogy->directMembersAt($alice, $march); // those Alice had sponsored by then $genealogy->ancestorsAt($diana, $march); // her sponsor line on that day $genealogy->descendantsAt($alice, $march, maxDepth: 1);
- A sponsorship counts from the second it was assigned, inclusive, and never ends: sponsors are not reassigned.
- A line is complete from the moment its last link was made. If Bob sponsored Charlie in January and Alice sponsored Bob in March, Charlie is below Alice from March, not January.
- A moment is compared as the same instant in the application's timezone, to the second, as volume's effective moments are.
- The history comes from the moment each assignment was made. There is no way to backdate one.
Cycle prevention
A member cannot be sponsored by anyone in its own sponsor subtree: if Alice sponsored Bob and Bob sponsored Charlie, Charlie cannot sponsor Alice. Refused assignments throw InvalidSponsorAssignment and change nothing. Every assignment runs in one transaction.
Ancestry is read from a closure table, never walked recursively. A member taking part in the sponsor tree cannot be deleted.
Placement genealogy
| Answers | Service | |
|---|---|---|
| Sponsor genealogy | who introduced whom | SponsorGenealogy |
| Placement genealogy | where a member is structurally placed | PlacementGenealogy |
The two are independent graphs over the same members. A member can be sponsored by Alice and placed under Bob; sponsoring never places, and placing never sponsors.
use PandaBear\Mlm\Genealogy\PlacementGenealogy; $placement = app(PlacementGenealogy::class); $placement->place($charlie, $bob); // Charlie is placed under Bob $placement->directParent($charlie); // Bob, or null for a placement root $placement->directChildren($bob); // members placed directly under Bob, in placement order $placement->ancestors($charlie); // PlacementRelative: member + depth, nearest first $placement->descendants($bob, maxDepth: 1); // As the placement tree stood at a moment, as for sponsorship: $placement->directParentAt($charlie, CarbonImmutable::parse('2026-06-01 00:00:00')); $placement->directChildrenAt($bob, CarbonImmutable::parse('2026-06-01 00:00:00')); $placement->ancestorsAt($charlie, CarbonImmutable::parse('2026-06-01 00:00:00')); $placement->descendantsAt($bob, CarbonImmutable::parse('2026-06-01 00:00:00'), maxDepth: 1);
- A member has at most one placement parent, and a member without one is a placement root. A program may have many.
- A parent may have any number of members placed under it. There is no capacity, position or slot in this generic layer.
- Member and parent must be in the same program. A member cannot be placed under itself or under anyone in its own placement subtree. Cycle checks only see placement, so placement may even run opposite to sponsorship.
- A placement is made once. There is no move or removal yet.
- Placement keeps its own history: a member sponsored in January and placed in March was sponsored, but not yet placed, in February.
- A member that already has members placed under it can still receive its first placement parent; its whole placement subtree is attached beneath that parent.
- Refused placements throw
InvalidPlacementAssignmentand change nothing. Every placement runs in one transaction.
Placement paths share the closure table with sponsor paths under their own tree_type, and never leak into sponsor queries. Binary left/right positions (Binary placement foundation) and matrix slots (Matrix foundation) are explicit overlays on these edges; spillover and automatic placement strategies are not implemented yet. A member taking part in the placement tree cannot be deleted.
Both genealogies are written only through their services. Raw query-builder or SQL writes to the edge or path tables bypass every graph rule: the database backs local invariants — one direct edge per member, one path per pair, foreign keys — but not acyclicity or consistency between edges and paths.
The design decisions are recorded in docs/adr.
Binary placement foundation
Binary is an overlay on the generic placement tree, not a replacement for it. Generic placement stays unlimited and positionless: a parent may still have any number of children. Only an edge explicitly given a side — BinarySide::Left or BinarySide::Right — is in the binary tree, and a parent has at most one binary child on each side.
use PandaBear\Mlm\Binary\BinaryGenealogy; use PandaBear\Mlm\Binary\BinaryPlacementManager; use PandaBear\Mlm\Binary\BinarySide; // A new placement with its side: placed through PlacementGenealogy, and // given the side, together or not at all. $position = app(BinaryPlacementManager::class)->place( $member, $parent, BinarySide::Left, ); // An existing generic placement given a side, from now on. $position = app(BinaryPlacementManager::class)->adopt( $placementEdge, BinarySide::Right, ); $binary = app(BinaryGenealogy::class); $binary->child($parent, BinarySide::Left); // the left child, or null $binary->directParent($member); // the binary parent, or null for a binary root $binary->positionOf($member); // BinaryPlacementPosition: parent, side, assigned_at $binary->ancestors($member); // BinaryRelative: member + depth, nearest first $binary->descendants($parent, maxDepth: 2); // As the binary tree stood at a moment: $binary->childAt($parent, BinarySide::Left, CarbonImmutable::parse('2026-06-01 00:00:00')); $binary->descendantsAt($parent, CarbonImmutable::parse('2026-06-01 00:00:00'));
- Sides are exactly
leftandright, assigned explicitly — never taken from the order children were placed in. There is no automatic placement: no spillover, next free slot or weaker-side rule. - Only binary edges belong to the binary tree. It keeps its own paths (
tree_typebinary), so a member placed only generically under a binary member stays out of every leg until its own edge is given a side. A member without a binary position is a binary root, even when it has a generic placement parent. - Adoption is not retroactive. An edge placed in January and adopted in March is binary from March: earlier activity never becomes binary activity. An adopted member brings its whole binary subtree with it.
- Adopting an edge again on the same side returns its position; the other side is refused.
place()is not a replay: a member that is already placed is refused, as placement refuses it — adopt its edge instead. - A taken side is refused with
InvalidBinaryPlacement, and a refusedplace()leaves no generic placement behind. A side never changes: there is no move or removal. - Binary never reads or writes sponsorship. There is one binary tree per program; several independent binary trees in one program are not supported yet.
- Positions are written only by
BinaryPlacementManager. A stored position that disagrees with its placement edge is refused withCorruptBinaryPlacementrather than read.
Existing placements are not adopted by the migration: the package cannot know which should be left, right or not binary at all. Pairing commissions on this structure are described in Binary pairing & carry.
Volume
Volume is a quantified business measurement attributed to a member — sales, points, any value a plan will later read. It is not money and not commission: recording it produces nothing else.
Recording
use Carbon\CarbonImmutable; use PandaBear\Mlm\Volume\Quantity; use PandaBear\Mlm\Volume\RecordVolume; use PandaBear\Mlm\Volume\VolumeRecorder; $entry = app(VolumeRecorder::class)->record(new RecordVolume( member: $member, type: 'sales', // your own identifier; the package defines none quantity: Quantity::of('25.5'), // a decimal string or an integer — never a float sourceType: 'order', sourceId: 'ORD-123', idempotencyKey: 'order:ORD-123:sales', effectiveAt: CarbonImmutable::parse('2026-06-30 18:00'), )); $entry->quantity->value(); // "25.5"
typeandsourceTypeare 1–64 lowercase letters, digits,.,-or_. Invalid input is refused, never rewritten.- Quantities are exact to six decimal places. A seventh is refused, not rounded. A recorded quantity must be positive, with at most 12 integer digits; totals may be larger and stay exact.
effective_atis when the activity counts, kept in the application's timezone;created_atis when it was stored.- The program is taken from the stored member.
Idempotent recording
The idempotency key identifies the request within its program. Recording the same key again with the same member, type, quantity, source and effective moment returns the entry already recorded. The same key with anything different throws ConflictingVolumeReplay. The source alone is not unique: one order may produce several entries.
Explicit reversal
Entries are immutable: they cannot be updated or deleted, not even through the model. A correction is a reversal — a second entry with the negated quantity, pointing at the original:
use PandaBear\Mlm\Volume\ReverseVolume; app(VolumeRecorder::class)->reverse(new ReverseVolume( entry: $entry, sourceType: 'refund', sourceId: 'RF-9', idempotencyKey: 'refund:RF-9', effectiveAt: CarbonImmutable::parse('2026-07-02 09:00'), ));
An entry is reversed at most once, and a reversal cannot be reversed. Replaying the same reversal returns it.
Totals
use PandaBear\Mlm\Volume\VolumeTotals; $totals = app(VolumeTotals::class); $totals->forMember($member, 'sales'); // Quantity, reversals netted $totals->forMember($member, 'sales', from: $june1, until: $july1);
A range counts entries by effective_at from from (inclusive) to until (exclusive); with both bounds, from must come before until. Totals cover the member's own entries only. Network totals and running balances are not implemented yet; money lives in the financial ledger.
Raw query-builder or SQL writes to mlm_volume_entries bypass every rule above. The database backs only its local invariants — one entry per key and program, one reversal per entry, foreign keys.
Metrics
A metric is a named numeric fact about a member, computed on demand from the package's data — never stored, and never a pass/fail judgement. Metrics are resolved by key through the MetricEngine:
use PandaBear\Mlm\Metrics\MetricContext; use PandaBear\Mlm\Metrics\MetricEngine; $value = app(MetricEngine::class)->resolve('member.volume', new MetricContext( member: $member, parameters: ['type' => 'sales'], from: $june1, // optional; [from, until), as for volume totals until: $july1, )); $value->value(); // "1250.5" — an exact string, never a float
Built-in: member.volume
The member's own net volume of one type — reversals included — over the optional range. It requires the type parameter and refuses any other parameter. It never looks at the member's genealogy.
Built-in: sponsor.network.volume and placement.network.volume
The net volume of one type recorded by the members below the member — in the sponsor tree or in the placement tree — over the optional range. The member's own volume is not included; that is member.volume. The two networks are independent: sponsoring someone adds nothing to your placement network.
app(MetricEngine::class)->resolve('sponsor.network.volume', new MetricContext( member: $alice, parameters: ['type' => 'sales', 'max_depth' => 3], // max_depth is optional from: $june1, until: $july1, ));
typeis required, as formember.volume.max_depth, an integer of 1 or more, counts only members at most that many steps down; without it, everyone below counts. No other parameter is accepted.- Network volume uses the genealogy that applied when the original activity happened. An entry counts for Alice only if its member was already below Alice at the entry's
effective_at. If Charlie sells in January and Alice sponsors Charlie in March, Alice's network never receives that January sale — not even when asked in April. - A reversal follows the activity it reverses. It reaches exactly the uplines the original sale reached, and appears in the period of its own
effective_at. If Bob sponsored Charlie before his January sale, Alice joined above Bob in March, and the sale is refunded in April, Bob's network shows +100 in January and −100 in April; Alice's shows neither. - The range
[from, until)selects entries by their owneffective_at, as everywhere else. - Computed on demand, exactly, in one query; nothing is stored.
These are generic graph figures, not a compensation plan: there are no generations, legs, sides, slots or pairing.
Built-in: binary.left.volume and binary.right.volume
The net volume of one type in one binary leg of the member: its child on that side and everyone below that child in the binary tree, over the optional range. The member's own volume, its other leg and members placed only generically never count, and an empty side is 0.
app(MetricEngine::class)->resolve('binary.left.volume', new MetricContext( member: $alice, parameters: ['type' => 'sales', 'max_depth' => 3], // max_depth is optional from: $june1, until: $july1, ));
typeis required and checked as for the other volume metrics.max_depthcounts from the member:1is the child on that side alone,2adds its binary children, and so on.- An entry counts only if, when the activity happened, the side had already been assigned and its member was already in that leg. A side assigned — or a subtree adopted — later never captures earlier activity.
- A reversal follows the activity it reverses, into the same leg or into none, and appears in the period of its own
effective_at. An activity and its reversal in one period net out. - Both are plan-configurable: a rule, and so a qualification or rank ladder, can use them.
These are structural figures; for pairing commissions and carry, see Binary pairing & carry.
Built-in: matrix.network.volume
The net volume of one type recorded by the members below the member in the matrix, over the optional range — see Matrix foundation. The member's own volume, generic-only members, sponsorship and the binary tree never count; max_depth counts from the member (1 is its direct matrix children). An entry counts only if its member was in the member's matrix when the activity happened, and a reversal follows the activity it reverses — so an edge adopted later never captures earlier activity. Plan-configurable, like the other network metrics.
Your own metrics
Implement PandaBear\Mlm\Metrics\Metric and register it from a service provider:
use PandaBear\Mlm\Metrics\MetricRegistry; public function register(): void { $this->callAfterResolving(MetricRegistry::class, function (MetricRegistry $metrics): void { $metrics->register(new AcmeRetentionMetric); // key(): "acme.retention" }); }
- Keys are 1–100 lowercase letters, digits,
.,-or_. Use your own namespace. - A key is registered once: a second registration throws
DuplicateMetricinstead of replacing the first. - Resolving an unknown key throws
UnknownMetric; it never answers zero. - Metrics are trusted code registered at boot. Nothing — no class name, formula or SQL — is ever loaded from the database.
Metrics only answer "what is the value"; qualification and rank build on them. Commission is not implemented.
Financial ledger & wallet
Money lives in a double-entry ledger. A wallet is one member's money in one currency; a system account is a program's own account under a key it chooses. Both are ledger accounts, and value moves between them only as balanced transactions: signed postings, one per account, summing to exactly zero, in one currency.
use PandaBear\Mlm\Finance\LedgerAccountManager; use PandaBear\Mlm\Finance\LedgerBalanceReader; use PandaBear\Mlm\Finance\LedgerPostingInput; use PandaBear\Mlm\Finance\LedgerRecorder; use PandaBear\Mlm\Finance\PostLedgerTransaction; use PandaBear\Mlm\Finance\ReverseLedgerTransaction; use PandaBear\Mlm\Finance\WalletManager; $wallet = app(WalletManager::class)->open($member, 'IDR'); // opens it with its account, or returns it $clearing = app(LedgerAccountManager::class)->openSystemAccount($program, 'IDR', 'adjustment.clearing'); $transaction = app(LedgerRecorder::class)->post(new PostLedgerTransaction( program: $program, currency: 'IDR', type: 'adjustment', sourceType: 'manual', sourceId: 'ADJ-1', idempotencyKey: 'adjustment:ADJ-1', occurredAt: now(), postings: [ LedgerPostingInput::of($clearing, '-100'), LedgerPostingInput::of($wallet->account, '100'), ], )); app(LedgerBalanceReader::class)->forWallet($wallet); // FinancialAmount "100" app(LedgerRecorder::class)->reverse(new ReverseLedgerTransaction( transaction: $transaction, sourceType: 'manual', sourceId: 'ADJ-1-REVERSAL', idempotencyKey: 'reversal:ADJ-1', occurredAt: now(), )); // the wallet is back to "0"
- Balances are derived. No wallet or account stores a balance:
LedgerBalanceReadersums the postings on every read, exactly, at any size. A balance may be negative — the ledger sets no floor; spending rules belong to the domains that spend. - Transactions are immutable. A correction is a reversal: a new transaction with every posting negated, under its own source, key and moment. The original is never changed, is reversed at most once, and a reversal is not reversed. Wallets, accounts, transactions and postings are read-only through Eloquent.
- Exact amounts.
FinancialAmountholds six decimal places, never rounds and never accepts a float. One posting holds at most 9,223,372,036,854.775807 either way, so it can always be reversed; a balance has no limit. - Replays are safe. A request under an idempotency key already used in the program returns the stored transaction if it is identical — postings in any order — and throws
ConflictingLedgerReplayotherwise. A rejected request writes nothing. - Every account is read from the database: it must belong to the transaction's program and hold its currency. A currency is three uppercase letters; the package keeps no currency list.
- No transfer, pending balance, fee, tax or rounding policy yet: those are later phases that post through this ledger. Commissions reach wallets through it — see Commission core — and leave through payouts.
Calculation runs
A calculation run is one successful calculation of one commission component, chosen by you, of a validated plan version, over a closed range [from, until). A component with the built-in driver commission.strategy selects a commission strategy — trusted code you register — and configures it:
use PandaBear\Mlm\Commission\CommissionCalculationContext; use PandaBear\Mlm\Commission\CommissionCandidate; use PandaBear\Mlm\Commission\CommissionStrategy; use PandaBear\Mlm\Commission\CommissionStrategyDefinition; use PandaBear\Mlm\Commission\CommissionStrategyRegistry; use PandaBear\Mlm\Exceptions\InvalidPlanDefinition; use PandaBear\Mlm\Models\Member; final class AcmeFlatRewardStrategy implements CommissionStrategy { public function key(): string { return 'acme.flat-reward'; } public function validate(CommissionStrategyDefinition $definition): void { if (array_keys($definition->parameters) !== ['amount']) { throw InvalidPlanDefinition::input('acme.flat-reward', 'it takes one "amount".'); } } public function calculate(CommissionCalculationContext $context): iterable { foreach (Member::on($context->connection)->where('program_id', $context->program->id)->get() as $member) { yield new CommissionCandidate( key: "member:{$member->member_code}", member: $member, amount: $context->definition->parameters['amount'], earnedAt: $context->until->subSecond(), trace: ['member_code' => $member->member_code], ); } } } // In a service provider's register(): $this->callAfterResolving(CommissionStrategyRegistry::class, function (CommissionStrategyRegistry $strategies): void { $strategies->register(new AcmeFlatRewardStrategy); });
use PandaBear\Mlm\Calculation\CalculationContext; use PandaBear\Mlm\Calculation\CalculationEngine; app(LedgerAccountManager::class)->openSystemAccount($program, 'IDR', 'commission.payable'); $component = $editor->addComponent($draft, key: 'monthly-reward', driver: 'commission.strategy', name: 'Monthly reward', parameters: [ 'strategy' => 'acme.flat-reward', 'currency' => 'IDR', 'source_account' => 'commission.payable', 'parameters' => ['amount' => '25'], ]); $lifecycle->markValidated($draft); $run = app(CalculationEngine::class)->calculate($component, new CalculationContext( from: $start, // required, included until: $end, // required, excluded idempotencyKey: 'monthly-reward:2026-06', )); $run->commissions; // CALCULATED commissions, in candidate key order
- The package ships four strategies —
direct-sponsor.fixed,unilevel.fixed(see Built-in strategies),direct-sponsor.proportionalandunilevel.proportional(see Proportional commission math) — and registers your own beside them. There is no formula language: strategies are code, configured by versioned parameters and rules. - The component's parameters are exactly
strategy,currency,source_accountandparameters. An unknown strategy, currency, account key or field blocksmarkValidated(). - The source account must already exist as a system account of the program in that currency — it is never created for you — and is fixed on the run.
- Only successful runs are stored, whole: a failing strategy or any invalid candidate — zero or negative amount, too large for one posting, duplicate key, another program's member, a trace that is not inert JSON — stores nothing.
- A calculation runs in its own transaction with one read snapshot, so every read of a strategy agrees; it refuses to start inside your transaction.
- Replaying an idempotency key returns the stored run without calculating again, even if data changed; the same key with another component or range throws
ConflictingCalculationReplay. A new calculation needs a new key. - Calculating moves no money, opens no wallet and approves nothing.
Built-in strategies
Both pay a fixed award per eligible original business entry, to sponsors of the entry's member as the sponsor line stood when the entry took effect.
$editor->addComponent($draft, key: 'direct-sponsor', driver: 'commission.strategy', name: 'Direct sponsor award', parameters: [ 'strategy' => 'direct-sponsor.fixed', 'currency' => 'IDR', 'source_account' => 'commission.payable', 'parameters' => [ 'volume_type' => 'sales', // entries of this volume type... 'source_type' => 'order', // ...from this source type... 'minimum_quantity' => '100', // ...of at least this quantity 'amount' => '10', // paid to the direct sponsor, per entry ], ]); $editor->addComponent($draft, key: 'depth-awards', driver: 'commission.strategy', name: 'Depth awards', parameters: [ 'strategy' => 'unilevel.fixed', 'currency' => 'IDR', 'source_account' => 'commission.payable', 'parameters' => [ 'volume_type' => 'sales', 'source_type' => 'order', 'minimum_quantity' => '100', 'levels' => [ ['depth' => 1, 'amount' => '10'], // the direct sponsor ['depth' => 2, 'amount' => '5'], // their sponsor ['depth' => 4, 'amount' => '1'], // depth 3 earns nothing ], ], ]); $lifecycle->markValidated($draft); $run = app(CalculationEngine::class)->calculate($component, new CalculationContext($start, $end, 'direct-sponsor:2026-06'));
- Fixed. The entry's quantity only makes it eligible; the award is the configured amount, whatever the quantity. For awards that follow the quantity, see Proportional commission math.
- Eligible entries are original volume entries of the run's program, of the configured volume and source types, effective in the run's range, with at least the minimum quantity (compared exactly;
0admits every entry). - Historical sponsors. A sponsor assigned after the entry, or an upline joined above it later, earns nothing from it. The placement tree is never read.
- Depths are physical sponsor depths: larger is further up. Only configured depths earn; there is no compression, and the order levels are listed in does not matter.
- One commission per entry and depth, keyed
volume-entry:<entry id>:depth:<d>, earned at the entry's moment, with a trace back to the entry and the sponsorship. - Reversals as of the run's cutoff. An entry reversed before the run's
untilearns nothing; a reversal atuntilor later belongs to a later range and changes nothing in this one. Commissions already calculated from it are corrected by an explicit clawback — see Commission adjustments. - Neither strategy takes rules: a component with rules is refused rather than having them ignored.
Proportional commission math
direct-sponsor.proportional and unilevel.proportional pay the entry's quantity × a unit_amount: money, in the component's currency, per one unit of the business measurement. It is not a percentage — a volume entry measures business, it is not money. Eligibility, historical sponsors, depths, reversal cutoffs and candidate keys are exactly those of the fixed strategies, which are unchanged.
[
'strategy' => 'direct-sponsor.proportional',
'currency' => 'IDR',
'source_account' => 'commission.payable',
'parameters' => [
'volume_type' => 'sales',
'source_type' => 'order',
'minimum_quantity' => '1',
'unit_amount' => '1.25', // per unit of quantity
'rounding' => 'half_even', // required: no default
],
]
[
'strategy' => 'unilevel.proportional',
'currency' => 'IDR',
'source_account' => 'commission.payable',
'parameters' => [
'volume_type' => 'sales',
'source_type' => 'order',
'minimum_quantity' => '1',
'rounding' => 'half_even', // one mode for every depth
'levels' => [
['depth' => 1, 'unit_amount' => '1.25'],
['depth' => 2, 'unit_amount' => '0.75'],
['depth' => 3, 'unit_amount' => '0.5'],
],
],
]
A quantity and a unit_amount each have up to six decimal places, so their product may have twelve; money keeps six. The product is computed exactly — no floats, no extension — and then rounded by the plan's mandatory rounding:
| Mode | Rule | 0.0000005 | 0.0000015 |
|---|---|---|---|
toward_zero |
drop what is below a millionth | 0 | 0.000001 |
away_from_zero |
add a millionth whenever anything remains | 0.000001 | 0.000002 |
half_up |
nearest; an exact half goes up | 0.000001 | 0.000002 |
half_even |
nearest; an exact half goes to the even millionth | 0 | 0.000002 |
- Rounding happens per candidate — separately for every source entry and depth — never after adding awards up.
- An award that rounds to zero is no commission.
unit_amountis a rate, not bounded by one posting; the rounded award is, and one too large fails the whole run. - Rounding is part of the versioned plan definition: never a package default, never chosen by currency. Every currency keeps six financial decimals.
- Each commission's trace records
quantity,unit_amount, the unroundedexact_amount,rounding, whether it wasrounded, and the finalamount. - A later reversal is clawed back by the stored, already-rounded amount — never recalculated.
Binary pairing & carry
Two built-in strategies pay on the binary legs. A pair takes the same pair_quantity from the left and the right leg of a member; what cannot pair yet is carried to the component's next run.
// A fixed amount for every whole pair. $editor->addComponent($draft, key: 'binary', driver: 'commission.strategy', name: 'Binary pairing', parameters: [ 'strategy' => 'binary.pairing.fixed', 'currency' => 'IDR', 'source_account' => 'commission.payable', 'parameters' => [ 'volume_type' => 'sales', 'pair_quantity' => '100', 'amount_per_pair' => '10', ], ]); // Or: the quantity the pairs consumed, times an amount per unit, rounded once. $editor->addComponent($draft, key: 'binary', driver: 'commission.strategy', name: 'Binary pairing', parameters: [ 'strategy' => 'binary.pairing.proportional', 'currency' => 'IDR', 'source_account' => 'commission.payable', 'parameters' => [ 'volume_type' => 'sales', 'pair_quantity' => '100', 'unit_amount' => '0.1', 'rounding' => 'half_even', ], ]); $engine->calculate($component, new CalculationContext($january1, $february1, 'binary:2026-01')); $engine->calculate($component, new CalculationContext($february1, $march1, 'binary:2026-02'));
- Pairing at the run's close. A run takes in every original entry of the volume type effective in its range, then pairs once per binary member: available = carry + added − reversed on each side; pairs = ⌊min(left, right) ÷
pair_quantity⌋, a whole number of any size; each side gives up pairs ×pair_quantity. With 250 left and 120 right and a pair quantity of 100: one pair, carry 150 and 20.pair_quantityis exact and may be fractional (2.5). - The award. Fixed: pairs ×
amount_per_pair, exactly. Proportional: consumed quantity ×unit_amount, rounded by the explicit mode (Proportional commission math). At most one commission per member and run, keyedbinary-pairing:<member id>, earned at the run'suntil, CALCULATED like every commission, with a trace of the pairing and carry. A proportional award that rounds to zero creates no commission, but its pairs still consume carry and are recorded. An award larger than one ledger posting fails the run. - Left/right carry, source by source. Carry is kept as FIFO lots: one original entry, in one side of one member's legs, with its quantity and what remains unpaired. The side is the binary tree's at the entry's own moment: generic-only placement, later adoption and the sponsor tree play no part, and a member's own activity is never in its own legs. Pairs consume the oldest source first, and every draw is recorded as an allocation, so a commission leads back to the entries it was paid on. Each run records a pairing result per member: carry before, added, reversed, available, pairs, consumed and carry after.
- State belongs to the plan component. The first run starts the component's state at its
from; each later run must start exactly where the last ended — an overlapping, repeated or gapped range is refused withInvalidBinaryPairingRange, and the same idempotency key replays the stored run without touching state. A new plan version starts with no carry; the old version's component keeps its own. Nothing is carried between versions automatically. - Atomic, serializable runs. The run, its commissions and its carry changes are committed together or not at all, in a serializable transaction retried when the database reports a conflict: two runs of one component never consume the same carry. Calling a pairing strategy's
calculate()directly only previews; authoritative results go throughCalculationEngine. - Reversals. An entry reversed before its run ends never pairs. A reversal of unpaired carry takes that carry back. A reversal of carry that was already paired is corrected in the run its moment falls in: the source's remaining carry is taken back, the pairs it fed are undone, and the same quantity returns to the other side's carry — newest source first — where it can pair again. Results record it as
left_restored/right_restored; earlier results, allocations and commissions never change, and an immutable journal (BinaryPairingCorrection,BinaryPairingRestoration) explains the correction. The commissions of undone pairs are then corrected financially by an explicit call — see Binary reversal correction.BinaryReversalImpactAnalyzer::analyze($reversal)shows, read-only, what a reversal reaches — allocated, invalidated, restored and net consumed quantity per lot, with the pairing results, runs and commissions involved. - No rules, carry expiry, pair caps or automatic placement yet.
Matrix foundation
A matrix is an overlay on the generic placement tree, like binary, but with numbered slots. A program has one matrix network whose width — how many slots every matrix parent has — is set once and never changes:
use PandaBear\Mlm\Matrix\MatrixGenealogy; use PandaBear\Mlm\Matrix\MatrixNetworkManager; use PandaBear\Mlm\Matrix\MatrixPlacementManager; $network = app(MatrixNetworkManager::class)->configure($program, 3); // slots 1, 2 and 3 $matrix = app(MatrixPlacementManager::class); $matrix->place($memberA, $parent, 1); // a generic placement and its slot, together $matrix->place($memberB, $parent, 2); $matrix->adopt($existingPlacementEdge, 3); // an existing generic edge, from now on $tree = app(MatrixGenealogy::class); $tree->child($parent, 2); // the member in slot 2, or null $tree->positionOf($memberA); // MatrixPlacementPosition: network, parent, slot, assigned_at $tree->ancestors($memberA); // MatrixRelative: member + depth, nearest first $tree->descendantsAt($parent, $june1, maxDepth: 2);
- Width is structure. A PHP integer from 1 to 100, configured once: the same width again returns the network, another width is refused (
InvalidMatrixNetwork). It is not a commission parameter; depth limits belong to metrics and strategies, and the matrix itself has no maximum depth. - Slots are explicit. A slot is a PHP integer from 1 to the width, holds one member, and is assigned once — never moved or removed. A taken slot is refused (
InvalidMatrixPlacement), and a refusedplace()leaves no generic placement behind. There is no automatic spillover or free-slot search yet: you choose the parent and the slot. - Generic placement is untouched. A parent keeps any number of generic children beside its full matrix; they are simply not in it. A generic-only edge below a matrix member stays outside the matrix until it is adopted itself.
- History-aware. A slot takes effect with its placement, an adoption from the moment of adoption — never before the edge was placed. Adopting an edge brings the member's own matrix subtree along from then on. Earlier activity never becomes matrix activity.
- Its own paths, under
tree_typematrix: independent of sponsorship, generic placement and binary. Stored rows that disagree with their edge, network or width are refused withCorruptMatrixPlacement. Existing placements are not adopted by the migration.
Compensation. matrix.fixed and matrix.proportional pay the members above the member of each eligible sale, at physical matrix depths, as the matrix stood when the sale took effect:
$editor->addComponent($draft, key: 'matrix', driver: 'commission.strategy', name: 'Matrix', parameters: [ 'strategy' => 'matrix.fixed', 'currency' => 'IDR', 'source_account' => 'commission.payable', 'parameters' => [ 'volume_type' => 'sales', 'source_type' => 'order', 'minimum_quantity' => '1', 'levels' => [['depth' => 1, 'amount' => '10'], ['depth' => 2, 'amount' => '5']], ], ]); $editor->addComponent($draft, key: 'matrix', driver: 'commission.strategy', name: 'Matrix', parameters: [ 'strategy' => 'matrix.proportional', 'currency' => 'IDR', 'source_account' => 'commission.payable', 'parameters' => [ 'volume_type' => 'sales', 'source_type' => 'order', 'minimum_quantity' => '1', 'rounding' => 'half_even', 'levels' => [['depth' => 1, 'unit_amount' => '0.1'], ['depth' => 2, 'unit_amount' => '0.05']], ], ]);
- Eligibility, levels, gaps without compression, rounding per entry and depth, and the reversal cutoff are exactly those of the depth-based strategies — read through the matrix instead of sponsorship.
unit_amountis money per unit of quantity, not a percentage. - Each commission records its source entry (
volume-entry), so a later reversal is clawed back byCommissionAdjustmentEngine::processVolumeReversal()like any source-entry commission. - Stateless: no carry, cycles or boards. Rules are refused.
Hybrid composition
A hybrid plan is composition: a plan version with two or more commission.strategy components — say binary.pairing.proportional, matrix.fixed and unilevel.fixed — calculated together as one batch. Hybrid does not create another genealogy, metric or strategy: each component pays through its own network, exactly as when calculated on its own.
use PandaBear\Mlm\Calculation\CalculationContext; use PandaBear\Mlm\Commission\HybridCalculationEngine; $result = app(HybridCalculationEngine::class)->calculate( $planVersion, new CalculationContext($january1, $february1, 'hybrid:2026-01'), $commissionPayable, // the system account every component is funded from ); $result->batch; // CalculationBatch: program, version, range, account, key, status $result->components(); // the commission components, by position then id $result->runs(); // one CalculationRun per component, in that order $result->commissionsOf($result->runs()[0]); // that run's commissions — never merged with another's
- One run per component, through the unchanged
CalculationEngine, over the batch's range and under a key derived from the batch and the component — so binary pairing keeps its own serializable transaction, carry and cursor, and stateless strategies stay stateless. Other components of the version, such as a rank ladder, are not calculated. A version with fewer than two commission components is refused. - Resumable, not one transaction. Each component commits its own run. If one fails, the batch stays
openwith the runs before it; calling again with the same request resumes it — no run is calculated twice, and a run committed before a crash is found again under its key. Once every component's run is linked the batch iscompleted; calling again returns it as stored. The same key with another version, range or account is refused. - Nothing is posted from an open batch:
CommissionPoster::post()refuses its commissions withIncompleteCalculationBatchuntil the batch completes, then posts as ever — binary correction guards and net amounts included. Runs calculated on their own post as before. - Commissions stay separate. Two components paying one member make two commissions, each with its own provenance. Later reversals are corrected by each domain's own engine —
processVolumeReversal()for source-entry strategies, the binary correction andprocessBinaryReversal()for pairing — never by a hybrid one. - Every component must be funded from the one source account given. Cross-component rules — matching, caps, "the greater of" — are not implemented.
Commission periods
A commission period is a program's calculation, review and release boundary: its active plan version, one range [from, until), one source account, and the earliest moment its commissions may become available. A program's periods never overlap.
use PandaBear\Mlm\Period\CommissionPeriodCalculator; use PandaBear\Mlm\Period\CommissionPeriodFinalizer; use PandaBear\Mlm\Period\CommissionPeriodManager; use PandaBear\Mlm\Period\CommissionPeriodReleaser; $period = app(CommissionPeriodManager::class)->create($program, $activeVersion, $commissionPayable, $january1, $february1, releaseAt: $february15, idempotencyKey: 'period:2026-01'); app(CommissionPeriodCalculator::class)->calculate($period); // one run per commission component // Review the commissions: CALCULATED -> PENDING -> APPROVED (or CANCELLED). app(CommissionPeriodFinalizer::class)->finalize($period); // APPROVED -> HELD app(CommissionPeriodReleaser::class)->release($period, $february15); // HELD -> AVAILABLE app(CommissionPoster::class)->post($commission); // AVAILABLE -> POSTED: the wallet is credited
- Status:
open → calculated → finalized → released, each step once. One commission component is calculated byCalculationEngine, two or more as a hybrid batch; a failed calculation leaves the period open and calling again resumes it. - Calculating closes the range. From the moment a period's calculation begins, a new business entry — original or reversal — whose own moment falls in its range is refused with
FinalizedCommissionPeriod, so no calculated result goes stale. A later reversal of one of its entries, dated after the period, is recorded as ever. - Finalize and release never move money. Finalizing needs every commission approved or cancelled, and no binary correction left unprocessed; releasing needs the release moment.
CommissionPosteris still the ledger boundary: a period's commission posts only when AVAILABLE in a released period (CommissionPeriodNotReleasedotherwise); commissions calculated outside periods still post straight from APPROVED. - Corrections before posting: a held or available commission is corrected like any unposted one — a partial binary share is recorded and posting pays what is left; a full correction cancels it.
CommissionPeriodTotals::of($period)gives calculated, adjustment, net and posted totals, exactly. A commission has no paid status: payouts spend the wallet, not commissions.
Commission core
A commission is reviewed, then posted to the member's wallet through the ledger:
use PandaBear\Mlm\Commission\CommissionLifecycle; use PandaBear\Mlm\Commission\CommissionPoster; $commission = $run->commissions->first(); // CALCULATED $commission = app(CommissionLifecycle::class)->markPending($commission); $commission = app(CommissionLifecycle::class)->approve($commission); $commission = app(CommissionPoster::class)->post($commission); // POSTED: the wallet is credited $commission = app(CommissionPoster::class)->reverse($commission, now()); // REVERSED: the credit is undone
CALCULATED → PENDING → APPROVED → POSTED → REVERSED, andCANCELLEDfrom any status beforePOSTED. Commissions of a commission period passAPPROVED → HELD → AVAILABLEfirst. Nothing else: approval is explicit, and posting a commission that is not approved is refused. One commission at a time.- Posting moves money only through the ledger: one balanced transaction debiting the run's source account and crediting the member's wallet — opened if need be — occurring when it was earned. It moves the commission's net amount: its calculated amount less any binary correction recorded before posting — the whole amount when there is none — and records it as
posted_amount($commission->postedAmount). Posting again returns the commission and moves nothing. - A commission a binary reversal has partly undone is not posted until that correction is processed (
UnresolvedBinaryCorrection), and one with nothing left to post is never posted for zero. - Reversal reverses that ledger transaction — exactly what was posted — at the moment you give; the original is untouched, and a commission is reversed once. A commission already partly corrected through the ledger is not reversed whole.
- What was calculated — member, amount, currency, earned-at, trace — never changes, and runs and commissions are read-only through Eloquent.
- Posted means the ledger credited the wallet; there is no paid status and no batch approval. Paying the wallet out is a payout.
Commission adjustments & source reversal clawback
When a business entry that commissions were paid on is reversed later, claw them back explicitly:
use PandaBear\Mlm\Commission\CommissionAdjustmentEngine; $reversal = $volumeRecorder->reverse(new ReverseVolume(entry: $sale, sourceType: 'refund', sourceId: 'RF-1', idempotencyKey: 'refund:RF-1', occurredAt: now())); $result = app(CommissionAdjustmentEngine::class)->processVolumeReversal($reversal); $result->adjustments; // one CommissionAdjustment per affected commission $result->count(CommissionAdjustmentOutcome::Reversed);
- CALCULATED, PENDING or APPROVED → cancelled. No money had moved, so none is debited.
- POSTED → reversed through
CommissionPoster, at the reversal's moment: the ledger reversal returns the money and the commission becomes REVERSED. - Already CANCELLED or REVERSED commissions are left as they are, and recorded as
already_cancelled/already_reversed. - Nothing is recalculated. A clawback never runs genealogy, strategy configuration, quantity-to-money conversion or rounding again: it negates the stored commission amount, for the commission's own member. What was calculated is never rewritten; each correction is an immutable
CommissionAdjustment. - Idempotent, and meant to be rerun. Calling it again returns the same adjustments and moves nothing. A historical run whose cutoff preceded the reversal may create commissions for the entry after an earlier call; calling again finds and corrects them.
- One call corrects everything it finds, or nothing.
- Provenance. The built-in source-entry strategies record
source_type = volume-entryand the entry's id on each commission; the trace stays audit output and is not the lookup. Your own strategy opts in by passingsource: CommissionSourceReference::volumeEntry($entry)to a candidate earned wholly from that entry. Commissions created before provenance existed are given it by the migration. - Recording volume never triggers this: call it where your application reverses volume.
Binary reversal correction
A binary commission is earned from many sources on both legs, so a reversal of one of them undoes only part of it. The correction happens in three explicit steps, and nothing already recorded is rewritten:
use PandaBear\Mlm\Commission\CommissionAdjustmentEngine; // January: P's left leg sells 30 and 70, the right leg 100. One pair of 100 // pays 100; the commission is approved and posted. $january = $calculationEngine->calculate($binaryComponent, new CalculationContext($january1, $february1, 'binary:2026-01')); $commission = $poster->post($lifecycle->approve($lifecycle->markPending($january->commissions->first()))); // February 5: the 30 sale is refunded. $reversal = $volumeRecorder->reverse(new ReverseVolume(entry: $sale30, sourceType: 'refund', sourceId: 'RF-1', idempotencyKey: 'refund:RF-1', effectiveAt: $february5)); // The run the reversal falls in corrects binary carry and pairing: the 30 // is taken back, 30 of January's pair is undone, and the right side gets 30 // of carry back. January's run, results, allocations and commission stay // as they were; a journal records the correction. $calculationEngine->calculate($binaryComponent, new CalculationContext($february1, $march1, 'binary:2026-02')); // The financial correction: 30 of the 100 paired was undone, so 30 of the // 100 paid comes back — here from the wallet, since it was posted. $result = app(CommissionAdjustmentEngine::class)->processBinaryReversal($reversal); $result->adjustments[0]->amount; // FinancialAmount -30 $result->adjustments[0]->outcome; // CommissionAdjustmentOutcome::Adjusted
- The share. With A the commission's stored amount, Q the quantity its pairing consumed and I the quantity reversals have undone of it, everything undone through a reversal is worth ⌊A × I ÷ Q⌋ financial millionths; the reversal's correction is that less what the reversals before it (by moment, then id) were worth. So corrections never add up to more than A, and exactly to A once the whole pairing is undone — three reversals of a third of 100 correct 33.333333, 33.333333 and 33.333334. The strategy is never run again: a fixed commission is not re-counted in whole pairs, and a proportional one is corrected from its rounded stored amount.
- Before posting (CALCULATED, PENDING, APPROVED): the correction is
recordedand moves no money; posting later pays what is left, and records it asposted_amount. When nothing is left, the commission iscancelled. - After posting: part of it moves back from the wallet to the run's source account in a ledger transaction of its own —
adjusted— and the commission stays POSTED, its original posting untouched. When a correction leaves nothing and none has moved money back before, the posting is reversed —reversed. When an earlier correction already moved part back, the rest moves back the same way, and the original is never reversed as well. The wallet may go negative. - CANCELLED or REVERSED commissions are left as they are:
already_cancelled,already_reversed. - One
CommissionAdjustmentper commission and reversal — sourcebinary-volume-reversal— even when the share is zero (under a millionth): the reversal is then known to be processed. A pairing whose award rounded to nothing has no commission and needs no adjustment. - Call it after the pairing run. Called before the run has taken the reversal in, it finds nothing and fakes nothing; calling again later finds the correction. Calling it again returns the same adjustments and moves nothing. One call corrects everything it finds, or nothing, and two calls — or a call and a post — racing on one commission serialize safely.
- Posting waits for it. A commission with a binary correction not yet processed is refused by
CommissionPoster::post()withUnresolvedBinaryCorrection, so a stale binary commission is never paid by accident. - The calculation engine never touches the ledger: the financial step is yours to orchestrate and retry.
Payout & settlement
A payout takes money out of a member's wallet: requested, approved — which reserves the funds through the ledger — processed by your provider, then settled or failed.
use PandaBear\Mlm\Payout\PayoutManager; $payouts = app(PayoutManager::class); $settlement = app(LedgerAccountManager::class)->openSystemAccount($program, 'IDR', 'payout.settlement'); $request = $payouts->request($member, $wallet, $settlement, '40', 'bank-account', 'dest:8f2c', $requestedAt, idempotencyKey: 'payout:W-1'); $payouts->approve($request, $approvedAt); // Wallet funds are now reserved: the wallet holds 40 less. $payouts->startProcessing($request, $processingAt); // Your provider or host performs the transfer. $payouts->settle( $request, settlementReference: $providerReference, at: $settledAt, );
When the transfer fails instead:
$payouts->fail( $request, reason: 'provider-rejected', at: $failedAt, );
- Status:
REQUESTED → APPROVED → PROCESSING → SETTLED,REQUESTED → CANCELLED, andAPPROVEDorPROCESSING → FAILED. Each step once, at the moment you give; repeating a step with the same facts returns the request. - The wallet is the source of truth. Approval reads the wallet's ledger balance under the wallet's lock and refuses more than it holds (
InsufficientPayoutBalance), so two payouts never spend the same funds; what is left stays in the wallet. Payouts never read commissions, periods, runs or the genealogy, and a commission has no paid status. - Approval moves the money once: one ledger transaction debiting the wallet and crediting your program's settlement system account of the same currency. Settlement records the provider's reference — unique in the program — and moves nothing more.
- Failure restores the reserved funds to the wallet by reversing that transaction. Cancelling, before approval, moves nothing.
- The destination is opaque — a type and a reference your host resolves. Never store credentials in it.
- A commission clawed back after its funds were paid out leaves the payout as it was: the wallet may go negative.
Group approved requests of one program and currency into a batch:
use PandaBear\Mlm\Payout\PayoutBatchManager; use PandaBear\Mlm\Payout\PayoutBatchTotals; $batches = app(PayoutBatchManager::class); $batch = $batches->create($program, 'IDR', idempotencyKey: 'batch:2026-03-01'); $batches->add($batch, $approvedRequest); // approved requests only, one batch each $batches->add($batch, $otherApprovedRequest); $batches->seal($batch, $sealedAt); $batches->startProcessing($batch, $processingAt); // every request moves to PROCESSING together $payouts->settle($approvedRequest, settlementReference: 'BANK-123', at: $settledAt); $payouts->fail($otherApprovedRequest, reason: 'account-closed', at: $failedAt); $batches->complete($batch, $completedAt); // once no request is still processing PayoutBatchTotals::of($batch); // counts; requested, reserved, settled and failed totals
- A batch groups; it never moves money. Every movement is its requests' own reservation or refund.
OPEN → SEALED → PROCESSING → COMPLETED, orOPEN → CANCELLED; a batched request is processed with its batch, then settles or fails on its own. - Provider integrations, webhooks, bank exports, fees, taxes, thresholds, scheduled payouts and currency conversion are not implemented.
Configuration
config/mlm.php holds technical settings only — where the package stores, queues and caches. Business plan rules such as pairing ratios, matrix sizes, commission percentages and rank requirements will never live in this file; they belong to versioned plans in the database.
| Key | Environment variable | Default |
|---|---|---|
mlm.database.connection |
MLM_DB_CONNECTION |
application default |
mlm.queue.connection |
MLM_QUEUE_CONNECTION |
application default |
mlm.queue.name |
MLM_QUEUE_NAME |
mlm |
mlm.cache.store |
MLM_CACHE_STORE |
application default |
mlm.cache.prefix |
MLM_CACHE_PREFIX |
mlm |
Configure it through the environment variables above. The config file is not publishable yet. An empty connection or store means the application's default; an empty queue name or cache prefix is rejected with PandaBear\Mlm\Exceptions\InvalidMlmConfiguration.
Inside the package, read these values through PandaBear\Mlm\Support\PandaMlmConfig, resolved from the container, rather than through config().
Known limitations
Deliberately out of scope for this release, not defects:
- no payment provider, bank or webhook integration — a payout records the provider's settlement reference;
- no tax, fees, KYC or AML;
- no automatic scheduling — periods, calculations and payouts run when an operator or the application asks;
- no automatic placement or matrix spillover, one matrix network per program, and no moving or removing a sponsor, placement or position;
- no manual or positive commission adjustments, and no persisted ranks;
- the Plan Builder edits one group of rule conditions; a rule with nested groups is removed and re-added;
- the genealogy explorer draws the tree as an indented table, because Panda Panel cannot load a plugin's Vue components;
- the suite has run on MySQL 9.6 and PostgreSQL 18.4, not on MySQL 8 or PostgreSQL 15;
- on Laravel 12, a guest opening a panel without a login of its own is redirected to the application's
loginroute, so the application needs one (Laravel 13 answers 401); - the direct sponsor and unilevel strategies look each entry's sponsor line up on its own — linear in the number of entries — and a calculation run holds its candidates in memory, about 7.5 KB per eligible entry (see docs/production-readiness.md);
- the repository has no continuous integration: the suite runs locally.
Roadmap
Planned, not implemented: percentage-of-money commissions, currency settlement precision, manual and positive commission adjustments, automatic clawback orchestration, qualified or ranked recipients for the built-in strategies, paid commissions, batch review, persisted qualification results, persisted ranks and rank history, rank promotion, demotion and maintenance, scheduled calculation periods, rules combined within a component other than a rank ladder, cross-component hybrid rules and caps, matching, generation, pool, leadership and fast-start bonuses, metric projections, running-balance projections, qualification rules, sponsor reassignment and correction, placement moves and removal, automatic placement strategies and matrix spillover, matrix cycling and re-entry, compressed matrices, matrix completion bonuses and boards, several matrix networks per program, matrix width changes, negative-balance and debt recovery after corrections, late binary events, binary carry expiry, pair caps and carry transfer between plan versions, multiple binary trees per program, business component drivers, performance and qualification, payment provider integrations, webhooks, bank exports and reconciliation, payout thresholds, scheduled payouts and currency conversion, transfers, pending and available balances, fees, taxes and rounding policies, balance projections, and a plugin-shipped Vue genealogy visualisation.
Testing
composer test
vendor/bin/pint --test config src tests database
By default the suite runs the package migrations against an in-memory SQLite database. It needs nothing else.
Against MySQL or PostgreSQL
The same suite runs against a real MySQL or PostgreSQL database when you opt in. Every test starts by dropping every table in that database, so give it an empty database made for the purpose: its name must contain test, and the suite refuses any other. The suite never creates or drops the database itself.
Connection details come from the environment only, never from a committed file:
| Variable | Required | Default |
|---|---|---|
MLM_TEST_MYSQL_DATABASE, MLM_TEST_PGSQL_DATABASE |
yes | |
MLM_TEST_MYSQL_USERNAME, MLM_TEST_PGSQL_USERNAME |
yes | |
MLM_TEST_MYSQL_PASSWORD, MLM_TEST_PGSQL_PASSWORD |
no | empty |
MLM_TEST_MYSQL_HOST, MLM_TEST_PGSQL_HOST |
no | 127.0.0.1 |
MLM_TEST_MYSQL_PORT, MLM_TEST_PGSQL_PORT |
no | 3306, 5432 |
export MLM_TEST_MYSQL_DATABASE=mlm_engine_test MLM_TEST_MYSQL_USERNAME=mlm composer test:mysql export MLM_TEST_PGSQL_DATABASE=mlm_engine_test MLM_TEST_PGSQL_USERNAME=mlm composer test:pgsql
composer test:mysql and composer test:pgsql set MLM_TEST_DATABASE to mysql or pgsql for you. The database user needs to create and drop tables there. On MySQL the concurrency tests also read performance_schema.data_lock_waits and performance_schema.threads to see which sessions are waiting on a lock.
Tests in the concurrency group run package operations in separate PHP processes, each with its own database session, and check how they interleave: racing cycle checks, sponsor against placement writes, racing volume records and reversals, pairing runs, commission posts and clawbacks — binary corrections included. They are skipped on SQLite. To run only them:
composer test:mysql -- --group concurrency
Documentation
- docs/public-api.md — the supported API
- docs/production-readiness.md — verification, invariants, concurrency, migrations and open blockers
- CHANGELOG.md
- docs/adr/ — the architecture decisions
License
MIT — see LICENSE.