★ Selling digital products using Laravel part 9: Serving ads on GitHub

Written by / Original link on Oct. 13, 2020

Our company has created a lot of open-source packages. At the moment of writing, we have over 200 packages, which have been downloaded nearly 100 million times. Because we think the package users might be interested in our paid offerings as well, we've put a small ad in the readme of each repo. In this blogpost I'll explain how we manage these ads using Laravel Nova and S3.

This post is a part of a series where we explore the source code of which you'll find in this repo on GitHub
  • Part 9: Serving ads on GitHub (you are here)

This is how an ad looks like at the laravel-tail repo.


The ad is not put at the top of the readme because users will want to see what they can do with a particular package before seeing an ad.

If a particular ad on a repo should be displayed until the end of time, the ad's image could be committed in the repo itself. But those ads should be rotated. Every week or month, we want to display another one. When launching a new product, it would be nice to let all repos show the new product's ad.

This problem is solved pragmatically. Let's take a look at the markdown of the "Support us" section of laravel-tail.

## Support us

[<img src="" width="419px" />](

You can see here that the image itself is not hosted on GitHub, but on an S3 bucket. Whenever we want to update the image on the repo, we don't change the repo's readme, but we update the image on the S3 bucket. Doing it this way also keeps our commit history clean. There's no need for a commit that updates the local at.

Also, note that you'll get redirected to when you click on the image. The Laravel app at will redirect the click to a page that matches the image being display. So it will redirect to,, and so on.

Administering ads and repositories in Nova #

Let's take a look at how all of this is implemented on our site. We use Laravel Nova to administer ads. In the Ads module, we can upload an ad's image and specify to which URL clicks should be redirected.


In the Repositories module, we can choose which ad should be displayed on a particular repo.


Syncing ad images to S3 #

In the codebase, there is an action called SyncRepositoryAdImageToGitHubAdsDiskAction that will copy the image of an ad to the S3 bucket whenever an Ad or Repository is changed. Let's take a look at the action first.

namespace App<span class="hljs-title">Actions;

use App<span class="hljs-title">Models<span class="hljs-title">Repository; use Illuminate<span class="hljs-title">Contracts<span class="hljs-title">Filesystem<span class="hljs-title">Filesystem; use Illuminate<span class="hljs-title">Support<span class="hljs-title">Facades<span class="hljs-title">Storage;

class SyncRepositoryAdImageToGitHubAdsDiskAction { public Filesystem $disk;

<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">__construct</span><span class="hljs-params">()</span>
    <span class="hljs-keyword">$this</span>-&gt;disk = Storage::disk(<span class="hljs-string">'github_ads'</span>);

<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">execute</span><span class="hljs-params">(Repository $repository)</span>: <span class="hljs-title">void</span>
        ? <span class="hljs-keyword">$this</span>-&gt;updateAdForRepository($repository)
        : <span class="hljs-keyword">$this</span>-&gt;removeAdForRepository($repository);

<span class="hljs-keyword">protected</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">updateAdForRepository</span><span class="hljs-params">(Repository $repository)</span>: <span class="hljs-title">void</span>
    <span class="hljs-keyword">$this</span>-&gt;disk-&gt;delete($repository-&gt;gitHubAdImagePath());

    <span class="hljs-keyword">$this</span>-&gt;disk-&gt;copy(

<span class="hljs-keyword">protected</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">removeAdForRepository</span><span class="hljs-params">(Repository $repository)</span>: <span class="hljs-title">void</span>
    <span class="hljs-keyword">$this</span>-&gt;disk-&gt;delete($repository-&gt;gitHubAdImagePath());


You've probably noticed that all ads are saved on a disk named github_ads. Here's the definition of that disk in the filesystems.php config file.

'disks' => [
	<span class="hljs-comment">// ...</span>

<span class="hljs-string">'github_ads'</span> =&gt; [
    <span class="hljs-string">'driver'</span> =&gt; env(<span class="hljs-string">'GITHUB_ADS_DISK_DRIVER'</span>),
    <span class="hljs-string">'root'</span> =&gt; env(<span class="hljs-string">'GITHUB_ADS_DISK_ROOT'</span>) ? storage_path(env(<span class="hljs-string">'GITHUB_ADS_DISK_ROOT'</span>)) : <span class="hljs-string">''</span>,
    <span class="hljs-string">'key'</span> =&gt; env(<span class="hljs-string">'GITHUB_ADS_DISK_KEY'</span>),
    <span class="hljs-string">'secret'</span> =&gt; env(<span class="hljs-string">'GITHUB_ADS_DISK_SECRET'</span>),
    <span class="hljs-string">'region'</span> =&gt; env(<span class="hljs-string">'GITHUB_ADS_DISK_REGION'</span>),
    <span class="hljs-string">'bucket'</span> =&gt; env(<span class="hljs-string">'GITHUB_ADS_DISK_BUCKET'</span>),
    <span class="hljs-string">'url'</span> =&gt; env(<span class="hljs-string">'GITHUB_ADS_DISK_URL'</span>),
    <span class="hljs-string">'options'</span> =&gt; [
        <span class="hljs-string">'CacheControl'</span> =&gt; <span class="hljs-string">'max-age=120, s-max-age=120'</span>,

In a local environment, the GITHUB_ADS_DISK_DRIVER is set to local. On production, that driver is set to s3, so an s3 bucket is used. Here's the content of that s3 bucket.


To make each file publicly available, we defined this policy on the bucket.


You probably also have noticed that we've set CacheControl on the disk definition, with a value of max-age=120, s-max-age=120. This will force S3 to add a Cache-Control header on all responses from the bucket.


This header needs to be present because otherwise, Camo (GitHub's image caching system) will indefinitely cache the image. We want to prevent that because otherwise, a change to an image would not be displayed.

I've already mentioned that SyncRepositoryAdImageToGitHubAdsDiskAction is executed whenever we change an Ad or Repository. Here's the relevant code in the Ad model.

public static function booted(): void
self::saved(function (Ad $ad): void {
if (in_array('image', $ad->getChanges())) {
$ad->repositories->each(function (Repository $repository) {
<span class="hljs-keyword">self</span>::deleting(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(Ad $ad)</span>: <span class="hljs-title">void</span> </span>{
    $ad-&gt;repositories-&gt;each(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(Repository $repository)</span> </span>{


And here's where SyncRepositoryAdImageToGitHubAdsDiskAction is called in the Repository model.

public static function booted()
self::saved(function (Repository $repository) {


Randomizing ads #

Each week we want to randomize the ad displayed on a repo. Here's the job that randomizes the ads.

use App<span class="hljs-title">Models<span class="hljs-title">Ad;
use App<span class="hljs-title">Models<span class="hljs-title">Repository;
use Illuminate<span class="hljs-title">Bus<span class="hljs-title">Queueable;
use Illuminate<span class="hljs-title">Contracts<span class="hljs-title">Queue<span class="hljs-title">ShouldQueue;
use Illuminate<span class="hljs-title">Foundation<span class="hljs-title">Bus<span class="hljs-title">Dispatchable;
use Illuminate<span class="hljs-title">Queue<span class="hljs-title">InteractsWithQueue;
use Illuminate<span class="hljs-title">Queue<span class="hljs-title">SerializesModels;

class RandomizeAdsOnGitHubRepositoriesJob implements ShouldQueue { use Dispatchable; use InteractsWithQueue; use Queueable; use SerializesModels;

<span class="hljs-keyword">public</span> <span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-title">handle</span><span class="hljs-params">()</span>
    $ads = Ad::active()-&gt;get();

    Repository::adShouldBeRandomized()-&gt;each(<span class="hljs-function"><span class="hljs-keyword">function</span> <span class="hljs-params">(Repository $repository)</span> <span class="hljs-title">use</span> <span class="hljs-params">($ads)</span> </span>{



That active scope just selects Ads of which the active attribute is set to true. When we launch a new product, we'll just make the ad for that new product the only active one, and dispatch RandomizeAdsOnGitHubRepositoriesJob. That will cause the ad for the new product to be displayed on all repos.

Redirecting ad clicks #

In the markdown code snippet above, you saw that the user would go to this URL when the ad image gets clicked:

That URL is handled on our app by this route:

Route::get('github-ad-click/{repositoryName}', RedirectGitHubAdClickController::class)->name('github-ad-click');

Here's the code of RedirectGitHubAdClickController:

namespace App<span class="hljs-title">Http<span class="hljs-title">Controllers;

use App<span class="hljs-title">Models<span class="hljs-title">Repository;

class RedirectGitHubAdClickController { public function __invoke(Repository $repository) { if (! $ad = $repository->ad) { return redirect()->route('products.index'); }

    <span class="hljs-keyword">return</span> redirect()-&gt;to($ad-&gt;click_redirect_url . <span class="hljs-string">"?utm_source=repo-{$repository-&gt;name}"</span>);


The code above is pretty straightforward. If the repository (in the example above laravel-tail) has a related Ad, we'll return a redirect to the URL defined on the ad. We'll tack on an utm_source. The source will allow us to track on which repo an ad was clicked.

If there's no ad, it'll return a redirect to the products page on

This series is continued in part 10: Miscellaneous interesting tidbits + outro.


« ★ Selling digital products using Laravel part 10: Miscellaneous interesting tidbits + outro - ★ Selling digital products using Laravel part 8: Mailing updates and news using Mailcoach »