Scout APM is PHP application performance monitoring designed for developers. With tracing logic that ties issues back to the line of code causing them, you can pinpoint n+1 queries, memory leaks, and other abnormalities in real time so you can knock them out and get back to building a great product. Start your free 14-day trial today and get the performance insight you need in less than 4 minutes.

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

Original – by Freek Van der Herten – 7 minute read

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 is how an ad looks like at the laravel-tail repo.

screenshot

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="https://github-ads.s3.eu-central-1.amazonaws.com/laravel-tail.jpg)?t=1" width="419px" />](https://spatie.be/github-ad-click/laravel-tail).

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 https://spatie.be/github-ad-click/laravel-tail when you click on the image. The Laravel app at spatie.be will redirect the click to a page that matches the image being display. So it will redirect to https://laravel-beyond-crud.com, https://front-line-php.com, 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.

screenshot

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

screenshot

Syncing ad images to S3

In the spatie.be 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\Actions;

use App\Models\Repository;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Support\Facades\Storage;

class SyncRepositoryAdImageToGitHubAdsDiskAction
{
    public Filesystem $disk;

    public function __construct()
    {
        $this->disk = Storage::disk('github_ads');
    }

    public function execute(Repository $repository): void
    {
        $repository->hasAdWithImage()
            ? $this->updateAdForRepository($repository)
            : $this->removeAdForRepository($repository);
    }

    protected function updateAdForRepository(Repository $repository): void
    {
        $this->disk->delete($repository->gitHubAdImagePath());

        $this->disk->copy(
            $repository->ad->image,
            $repository->gitHubAdImagePath(),
        );
    }

    protected function removeAdForRepository(Repository $repository): void
    {
        $this->disk->delete($repository->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' => [

		// ...

    'github_ads' => [
        'driver' => env('GITHUB_ADS_DISK_DRIVER'),
        'root' => env('GITHUB_ADS_DISK_ROOT') ? storage_path(env('GITHUB_ADS_DISK_ROOT')) : '',
        'key' => env('GITHUB_ADS_DISK_KEY'),
        'secret' => env('GITHUB_ADS_DISK_SECRET'),
        'region' => env('GITHUB_ADS_DISK_REGION'),
        'bucket' => env('GITHUB_ADS_DISK_BUCKET'),
        'url' => env('GITHUB_ADS_DISK_URL'),
        'options' => [
            'CacheControl' => 'max-age=120, s-max-age=120',
        ],
    ],

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.

screenshot

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

screenshot

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.

screenshot

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) {
                app(SyncRepositoryAdImageToGitHubAdsDiskAction::class)->execute($repository);
            });
        }
    });

    self::deleting(function (Ad $ad): void {
        $ad->repositories->each(function (Repository $repository) {
            app(SyncRepositoryAdImageToGitHubAdsDiskAction::class)->execute($repository);
        });
    });
}

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

public static function booted()
{
    self::saved(function (Repository $repository) {
        $repository->load('ad');

        app(SyncRepositoryAdImageToGitHubAdsDiskAction::class)->execute($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\Models\Ad;
use App\Models\Repository;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

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

    public function handle()
    {
        $ads = Ad::active()->get();

        Repository::adShouldBeRandomized()->each(function (Repository $repository) use ($ads) {
            $repository->ad()->associate($ads->random());

            $repository->save();
        });
    }
}

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: https://spatie.be/github-ad-click/laravel-tail.

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\Http\Controllers;

use App\Models\Repository;

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

        return redirect()->to($ad->click_redirect_url . "?utm_source=repo-{$repository->name}");
    }
}

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 spatie.be.

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

Stay up to date with all things Laravel, PHP, and JavaScript.

Follow me on Twitter. I regularly tweet out programming tips, and what I myself have learned in ongoing projects.

Every two weeks I send out a newsletter containing lots of interesting stuff for the modern PHP developer.

Expect quick tips & tricks, interesting tutorials, opinions and packages. Because I work with Laravel every day there is an emphasis on that framework.

Rest assured that I will only use your email address to send you the newsletter and will not use it for any other purposes.

Comments

Webmentions

Roman Pronskiy liked on 16th October 2020
Robin Dirksen liked on 13th October 2020
Richard Radermacher liked on 13th October 2020