Miguel Piedrafita

Miguel Piedrafita

Building an affiliate program with Laravel

I recently built a simple affiliate program for Sitesauce. Here's a short article detailing how I implemented it.

Note: I built this from scratch as a way to have more control over the affiliate experience, but you may want to use a platform like Rewardful to implement something similar with little work on your part.

The basics of our program will be the following: users can register for our referral program and get a special link to share. When someone registers through their link, they'll be marked as referred by our user, and they'll earn a comission on all future purchases they make.

Let's first figure out how we want our affiliate experience to be. I was initially planning to take the Fathom approach, where referred users are linked to a special referral page which marks them as referred, but ended up deciding to go with an approach similar to what Rewardful does, allowing our affiliates to append ?via=miguel to any URL of our marketing site to count them as referrals.

So let's start by creating the script that allows us to register referred users when they visit a page with the via query param. The code is pretty straightforward, if the parameter exists, we store it in a cookie for 30 days. Since our marketing site and Laravel app are on different subdomains, we add a . in front of the domain, which makes it available on all subdomains.

import Cookies from 'js-cookie'

const via = new URL(location.href).searchParams.get('via')

if (via) {
    Cookies.set('sitesauce_affiliate', via, {
	    expires: 30,
		domain: '.sitesauce.app',
		secure: true,
		sameSite: 'lax',
	})
}

This does the trick functionality-wise, but I wanted to take it a step further by showing a special call-to-action and the name of the affiliate, which should both make it more compelling and make it clear you just followed a referral link. Here's a mockup of how I wanted my banner to look like when visiting a referral link.

A GIF showing the referral banner

To make that work though, we need to store more than the affiliate tag. Let's prototype an imaginary API that returns some data from a tag, and get our example working.

import axios from 'axios'
import Cookies from 'js-cookie'

const via = new URL(location.href).searchParams.get('via')

if (via) {
	axios
		.post(`https://app.sitesauce.app/api/affiliate/${encodeURIcomponent(this.via)}`)
		.then(response => {
			Cookies.set('sitesauce_affiliate', response.data, { expires: 30, domain: '.sitesauce.app', secure: true, sameSite: 'lax' })
		})
		.catch(error => {
			if (!error.response || error.response.status !== 404) return console.log('Something went wrong')

			console.log('Affiliate does not exist. Register for our referral program here: https://app.sitesauce.app/affiliate')
		})
}

See that encodeURIComponent call when constructing the URL? It's there to protect us from a Path Traversal vulnerability. We're sending a request to /api/referral/:via, so if someone crafted a link containing ?via=../../logout, users would be logged out when clicking on the link. No much harm done in this scenario, but you can probably imagine many others where this would not be good.

Since the Sitesauce landing already uses Alpine in a few places, let's build our banner as an Alpine component. Props to my friend Ryan for helping out why my transitions weren't working.

<div x-data="{ ...component() }" x-cloak x-init="init()">
	<template x-if="affiliate">
		<div>
			<img :src="affiliate.avatar" class="h-8 w-8 rounded-full mr-2" />
			<p>Your friend <span x-text="affiliate.name"></span> has invited you to try Sitesauce</p>
			<button>Start your trial</button>
		</div>
	</template>
</div>

<script>
	import axios from 'axios'
	import Cookies from 'js-cookie'

	// We're using template tags and $nextTick so that transitions for our banner get executed, thanks Ryan for helping me figure this out!
	window.component = () => ({
		affiliate: null,
		via: new URL(location.href).searchParams.get('via'),
		init() {
			if (!this.via) return this.$nextTick(() => (this.affiliate = Cookies.getJSON('sitesauce.affiliate')))

			axios
				.post(`https://app.sitesauce.app/api/affiliate/${encodeURIComponent(this.via)}`)
				.then(response => {
					this.$nextTick(() => (this.affiliate = response.data))

					Cookies.set('sitesauce_affiliate', response.data, {
						expires: 30,
						domain: '.sitesauce.app',
						secure: true,
						sameSite: 'lax',
					})
				})
				.catch(error => {
					if (!error.response || error.response.status !== 404) return console.log('Something went wrong')

					console.log('Affiliate does not exist. Register for our referral program here: https://app.sitesauce.app/affiliate')
				})
		},
	})
</script>

Let's now bring that imaginary API to life so we can see the whole thing working. Instead of creating a new Affiliate model, I'll add a few fields to our existing User model. Ignore the last two for now, we'll get back to those later.

class AddAffiliateColumnsToUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->string('affiliate_tag')->nullable();
            $table->string('referred_by')->nullable();

            $table->string('paypal_email')->nullable();
            $table->timestamp('cashed_out_at')->nullable();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropColumn('affiliate_tag', 'referred_by', 'paypal_email', 'cashed_out_at');
        });
    }
}

For our API route, we'll use the new binding fields routing feature (available on Laravel 7.X) to get everything working with a single line of code.

Route::post('affiliate/{user:affiliate_tag}', function (User $user) {
    return $user->only('id', 'name', 'avatar', 'affiliate_tag');
})->middleware('throttle:30,1');

Before we start working on marking users as referred on registration, we need to make sure Laravel can read our cookie. Since it's not encrypted, we'll need to add it to the exceptions list of our EncryptCookies middleware.

use Illuminate\Cookie\Middleware\EncryptCookies as Middleware;

class EncryptCookies extends Middleware
{
    /**
    * The names of the cookies that should not be encrypted
    *
    * @var array
    */
    protected $except = [
        'sitesauce_affiliate',
    ];
}

With that out of the way, let's move to marking users as referred. We can use the authenticated method on our RegisterController to execute logic after our user has been created. We'll make sure that both the cookie and the affiliate exist, and mark the user as referred if they do.

/**
 * The user has been registered.
 *
 * @param  \Illuminate\Http\Request  $request
 * @param  \App\User  $user
 */
protected function registered(Request $request, User $user)
{
    if (! $request->hasCookie('sitesauce_affiliate')) return;

    $referral = json_decode($request->cookie('sitesauce_affiliate'), true)['affiliate_tag'];

    if (! User::where('affiliate_tag', $referral)->exists()) return;

    $user->update([
        'referred_by' => $referral,
    ]);
}

We're also going to need a way to retrieve a list of users our affiliate has referred. We can achieve this by creating a hasMany relationship with the User model from our User model.

class User extends Model
{
    public function referred()
    {
        return $this->hasMany(self::class, 'referred_by', 'affiliate_tag');
    }
}

Let's now take a break from the backend to design a nice page where our users can register for the program. We'll allow them to select their tag and ask them for their PayPal email, so they can cash their earnings out. Here's how my design turned out:

Once our affiliate has registered, we should also allow them to update both their email and their tag. If they update their tag, we'll need to update it across all their referred users to make sure reporting doesn't break. We'll do it on a database transaction to ensure that, if one of the two operations fail (updating the tag across referred users and updating the tag on our affiliate user), both of them are reverted.

public function update(Request $request)
{
	$request->validate([
		'affiliate_tag' => ['required', 'string', 'min:3', 'max:255', Rule::unique('users')->ignoreModel($request->user())],
		'paypal_email'  => ['required', 'string', 'max:255', 'email'],
	]);

	DB::transaction(function () use ($request) {
		if ($request->input('affiliate_tag') != $request->user()->affiliate_tag) {
			User::where('referred_by', $request->user()->affiliate_tag)
				->update(['referred_by' => $request->input('affiliate_tag')]);
		}

		$request->user()->update([
			'affiliate_tag' => $request->input('affiliate_tag'),
			'paypal_email' => $request->input('paypal_email'),
		]);
	});

	return redirect()->route('affiliate');
}

Finally, we'll need a way of calculating how much our affiliate has earned. We can do this by adding together all invoices from referred users and calculating a percentage. To calculate the current balance, we'll only take into account invoices since the last payout date. We'll use Mattias' percentages package to make the calculation clearer.

use Mattiasgeniar\Percentage\Percentage;

const COMMISSION_PERCENTAGE = 20;

public function getReferralBalance() : int
{
    return Percentage::of(static::COMISSION_PERCENTAGE,
        $this->referred
            ->map(fn (User $user) => $user->invoices(false, ['created' => ['gte' => optional($this->cashed_out_at)->timestamp]]))
            ->flatten()
            ->map(fn (\Stripe\Invoice $invoice) => $invoice->subtotal)
            ->sum()
    );
}

The only thing left to do is to manually send affiliates their earnings at the end of each month and updating the cashed_out_at timestamp. Here's how our result looks:

Hope this was useful! This article started as a Twitter thread, you may want to follow me there for more semi-live-coding and tips. And if you'd like to see me do something like this in video format, you can subscribe to my YouTube channel, as I'm planning to produce more video content this summer. Have a great day!