For most of this series I have been shipping the parts of a SaaS that you can see: auth, multi-tenancy, an admin console, billing. The affiliate program is the part I deliberately left for last, because it is the one most likely to get built as a special case and regretted later. This post is about finishing it without that regret.
LaraFoundry is a reusable Laravel SaaS core I am extracting in public from a CRM that already runs in production, one module at a time. The core is free and stays free for everything except money. The day a business wants to charge its own customers, that is the paid billing add-on. An affiliate program is squarely on the paid side: it is a way to grow paid revenue, so it ships inside that add-on, on top of the billing engine I wrote about a few posts back.
Here is what "done" means concretely. A partner identity with a unique referral code. A capture link that attributes a new tenant to the partner who sent them. Commission that accrues when a referred tenant pays, on rules you choose. A clawback when that payment is refunded or fails. A super-admin payout console with per-currency totals and a CSV export. And a self-serve dashboard where a partner sees their own referrals and balance. All of it under 314 Pest tests.
Two decisions shaped every line, and they are the whole point of the post.
Decision one: an engine, not a scheme
The easy way to build an affiliate program is to encode the one you want. Twenty percent, recurring, paid on every invoice. It works, you ship in a day, and you have just fenced every host that uses your core into your commission policy. A reusable core cannot do that. So instead of a scheme I built an engine, configured along three axes that do not know about each other.
'affiliate' => [
'enabled' => true,
'eligibility' => 'auto', // auto | self_serve | admin
'commission' => [
'trigger' => 'recurring', // first_payment | recurring | lifetime
'window_months' => 12,
'basis' => 'per_plan', // per_plan | percent | fixed
],
],
Eligibility answers who becomes a partner. auto mints a code for every user so anyone can share a link. self_serve lets users opt in but holds them pending an admin approval. admin means partners are invited only. Trigger answers which payments pay out. first_payment is a one-time bounty per referral. recurring keeps paying for a window, twelve months by default, then stops. lifetime never stops. Basis answers how the amount is figured. per_plan reads a commission amount from the plan dictionary, percent takes a share of the net payment, fixed is a flat fee. A per-partner override can raise or lower the percent rate for a specific affiliate.
Because the three axes are orthogonal, any combination is valid and the engine does not branch into a tangle of special cases. The trigger decides whether a given payment is eligible at all; the basis, independently, decides the amount once it is. The default is the sensible one (a code for everyone, recurring for a year, priced per plan) but it is a default, not a law.
Decision two: zero host code
The part I am happiest with is that turning a partner program on does not add a single line of PHP to the host application. It rides two events that already existed before the affiliate program did.
// Free core, public: fired whenever a tenant is created.
event(new CompanyCreated($company, $owner));
// Paid add-on, fired whenever a company payment is processed.
event(new CompanyPaymentProcessed($payment));
CompanyCreated is part of the free core. It fires on every signup regardless of billing or affiliates, because the core needs it for its own bookkeeping. CompanyPaymentProcessed is the billing add-on's own event, fired by the gateway webhook listener when a charge settles. Neither was invented for the affiliate program. The affiliate program simply subscribes to them.
Event::listen(CompanyCreated::class, AttributeReferral::class);
Event::listen(CompanyPaymentProcessed::class, AccrueAffiliateCommission::class);
AttributeReferral reads the referral cookie set by the capture link and locks the new company to a partner. AccrueAffiliateCommission runs the trigger and basis logic and writes a commission row. Both listeners, and the engine behind them, are the proprietary part of the add-on, so I am describing them rather than dumping them. The shape is what matters here: the add-on registers these listeners from its own service provider, gated behind the affiliate.enabled flag. A host turns the feature on in config, publishes the two Inertia pages, and is finished. There is no controller to write, no event to fire by hand, no model to touch. The seam was already in the core; the add-on plugs into it.
Attribution is lock-once, and not an oracle
A referral link is /r/{code}. Hitting it drops an encrypted cookie and redirects. The redirect is uniform: it goes to the same place whether the code is real or not, so the endpoint never becomes an oracle you can poke to enumerate valid codes. When that visitor later creates a company, AttributeReferral fires.
Attribution locks once. The first partner to refer a company keeps it; a later link cannot steal an existing tenant, because the referral row is keyed unique on the referred company. Self-referral is blocked, so a partner cannot sign up under their own code. And the partner gets exactly one bounty per referral under the first_payment trigger, tracked so an out-of-order webhook cannot pay it twice. That last one was a real bug an adversarial review pass caught before it shipped: the idempotency key was per payment, not per referral, which under a replayed or out-of-order webhook could have accrued two first-payment bounties. The fix was to make "first payment" mean "this referral has no live commission yet," and a regression test now guards it.
Money stays honest
Commissions are stored per currency, in integer minor units, with no converter anywhere. This is the same rule the rest of the billing engine follows. A partner who refers customers paying in euros, zlotys and dollars accrues three separate balances, and the console reports three separate totals. There is no FX rounding turning real money into an approximation of a dollar figure.
Payout is manual on purpose, and I want to be clear that this is a feature, not a gap. The add-on has no money-out payment provider, and it does not pretend to have one. What it gives the operator is the truth: a report of who is owed what, per currency, with a state machine they drive by hand. A commission starts pending when it accrues. The operator approves a batch (pending to approved), then marks them paid once the money has actually left, attaching a payout reference (approved to paid). A commission can be voided manually for a refund the automatic clawback could not catch. Speaking of which: when a payment is refunded or fails, the matching commission for that same invoice is clawed back automatically, unless it has already been paid out, in which case it is left alone for a human to reconcile.
The console exports the current filtered report as a CSV, and that CSV neutralises spreadsheet formula injection: any cell whose text starts with one of the dangerous characters is prefixed so a spreadsheet treats it as literal text, not a formula. A payout reference an operator typed should never execute in Excel.
The two surfaces
There are two UI surfaces, both Inertia and Vue pages the host publishes, the same delivery pattern the rest of the core's frontend uses, so the add-on does not drag in a second build pipeline.
The super-admin gets the payout console: the commission report with per-currency totals by status, the bulk approve / mark-paid / void actions, and the CSV export. The partner gets a self-serve dashboard scoped strictly to their own user id: their code, their referrals, and their balance by currency and status. The dashboard never leaks another partner's numbers, which the tests assert directly.
The proof, because every release claims one
314 Pest tests in the add-on, Pint clean, and a two-agent adversarial review (one for security, one for correctness) before this could be called done. The security pass came back with no high findings: the partner dashboard scope is airtight, the console is gated three ways (route, policy, form request), the payout state machine is safe against id tampering, and attribution is self-referral-blocked and oracle-free. The correctness pass found the out-of-order double-bounty I described above, plus a quieter one: a percent rate provided through an environment variable arrives as a string, and the coercion had been rejecting numeric strings and silently falling to zero percent, which would have paid nobody. Both are fixed with regression tests. A payout you cannot trust is worse than no program at all, and the failure modes here are the quiet kind.
The honesty section
Every post in this series gets one. This is the new code, not the proven-by-years code. The core underneath the affiliate program comes out of a CRM that runs in production, but the affiliate engine itself is greenfield, written for this milestone, and proven by tests rather than by time. Its accrual rides on CompanyPaymentProcessed, which is driven by the billing gateways I have built and tested but not yet run against a live merchant account end to end. So I am not going to tell you commissions have been earned and paid in production, because they have not. They have been earned and paid across 314 tests.
And it is a lean engine, not a full affiliate platform. There is no automated money-out, no fraud scoring, no tiered partner ladders. Those are deliberate non-goals for a first version. What is here is the spine: attribution, a configurable accrual engine, clawback, a payout console and a partner dashboard, drawn so a host can grow it without forking it.
The thread, again
Every phase in this series lands on the same shape. The interesting engineering is fine, but the real lesson is always a default that was right for one app and wrong for a reusable one. For the affiliate program it was the commission scheme itself. The CRM I extracted from would only ever have needed one policy, because it serves one business, and hardcoding twenty-percent-recurring would have been completely reasonable there. In a reusable core it would have quietly become everyone's policy. The work was not building referral tracking. It was refusing to bake in one company's idea of what an affiliate program owes, and wiring the whole thing so a host turns it on without touching their own code.
Follow along
- Code, versioned and Pest-tested before each tag: github.com/dmitryisaenko/larafoundry
- The project and roadmap: larafoundry.com
Top comments (0)