How to handle a Private Beta with access code for your new app in Laravel

Tony JoeTony Joe
7 min read

Do you have an app that needs testing before the big launch? Before opening a public beta, it is best to keep the doors ajar and start with a private beta.

In this tutorial, we will see how to make our Laravel application capable of requesting an access code before starting to use it.


First, the basics: What is a Private Beta?

A Private Beta with Access Code

A private (or closed) beta is an app accessible by invitation to a small group of testers. In contrast, a public (or open) beta is accessible to anyone interested.

Generally, the private beta is not ready to be used by everyone for various reasons: perhaps some features are still missing or there are potential scalability problems or the app simply needs to be shown to potential investors before the actual launch on the market.


Steps

  1. Make the Model and Migration for Invitations

  2. Add some Config parameters

  3. Make a Service class

  4. Make the Routes, the View and the Controller

  5. Make the Middleware: This is where the work happens

  6. Check that everything works!


1. Make the Model and Migration for Invitations

Let's create a Model named PrivateBetaInvitation and its migration:

php artisan make:model PrivateBetaInvitation --migration

In the migration, let's add the following fields to the table private_beta_invitations:

FieldDescription
statusvalues: pending, waiting, active, archived
access_codethe code that users (testers) will receive and must enter to access the app
expire_atoptional
num_requestsbasic stats
last_access_at-

The migration:

// database/migrations/XXXX_create_private_beta_invitations_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('private_beta_invitations', function (Blueprint $table) {
            $table->id();

            $table->string('email')->index();
            $table->string('status', 16)->index()->default('pending')
                ->comment('pending|waiting|active|archived');
            $table->string('access_code', 32)->index()
                ->comment('the code that users (testers) will receive and must enter to access the app');
            $table->timestamp('expire_at')->nullable();
            $table->unsignedInteger('num_requests')->default(0);
            $table->timestamp('last_access_at')->nullable();

            $table->timestamps();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('private_beta_invitations');
    }
};

Migrate:

php artisan migrate

The Model PrivateBetaInvitation:

// app/Models/PrivateBetaInvitation.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class PrivateBetaInvitation extends Model
{
    protected $fillable = [
        'email',
        'status',
        'access_code',
        'expire_at',
    ];

    protected $casts = [
        'expire_at' => 'datetime',
        'num_requests' => 'integer',
        'last_access_at' => 'datetime',
    ];
}

2. Add some Config parameters

Add variables in .env file:

PRIVATE_BETA_ENABLED=true

# whitelist IPs example: <my-home-ip>,<my-office-ip>
PRIVATE_BETA_WHITELIST_IPS=""

Then, refer to these variables in the config/app.php file:

// config/app.php

return [
    // ...

    'private-beta' => [
        /*
        | When `true`, the app can't be browsed without an access code!
        */
        'enabled' => env('PRIVATE_BETA_ENABLED', false),

        /*
        | Comma separated IP list for exclude the access code required when Private Beta is `enabled`.
        | You can put here YOUR IP.
        */
        'whitelist_ips' => env('PRIVATE_BETA_WHITELIST_IPS', ''),
    ],

];

💡 SMALL TIP: In case, remember to launch php artisan config:clear or php artisan config:cache


3. Make a Service class

Let's create a Service class PrivateBetaService where we put all the logic.

Since there is no "make:service" command in Laravel (yet?) we can simply create an empty file in the app/Services folder by running:

mkdir app/Services && touch app/Services/PrivateBetaService.php

Before implementation, here is an overview of the method interfaces of the Service class:

// app/Services/PrivateBetaService.php

namespace App\Services;

use App\Models\PrivateBetaInvitation;

class PrivateBetaService
{

    public function isEnabled(): bool
    {
        // ...
    }

    public function isIpWhitelisted(?string $ip): bool
    {
        // ...
    }

    public function checkAccessCode(string $accessCode): PrivateBetaInvitation
    {
        // ...
    }

    public function access(string $accessCode): PrivateBetaInvitation
    {
        // ...
    }

}

Finally, the full implementation of the Service class:

// app/Services/PrivateBeteService.php

namespace App\Services;

use App\Models\PrivateBetaInvitation;

class PrivateBetaService
{

    public function isEnabled(): bool
    {
        return config('app.private-beta.enabled', false);
    }

    public function isIpWhitelisted(?string $ip): bool
    {
        if (blank($ip)) {
            return false;
        }

        $whitelistIps = config('app.private-beta.whitelist_ips');
        if (blank($whitelistIps)) {
            return false;
        }

        return \Str::of($whitelistIps)
            ->split('/[\s,]+/')
            ->contains($ip);
    }

    public function checkAccessCode(string $accessCode): PrivateBetaInvitation
    {
        if (! $this->isEnabled()) {
            throw new \Exception(__('Private Beta is not enabled!'));
        }

        $invitation = PrivateBetaInvitation::query()
            ->where('access_code', $accessCode)
            ->latest()
            ->first();

        if (! $invitation) {
            throw new \Exception(__('The access code you sent is invalid'));
        }

        if ($invitation->status != 'active') {
            $msg = match($invitation->status) {
                'pending'  => __('Your access code is currenty pending.'),
                'waiting'  => __('Your access code is currenty waiting for the staff.'),
                'archived' => __('Your access code has expired.'),
                default    => __('The access code you sent is invalid!')
            };

            throw new \Exception($msg);
        }

        if ($invitation->expire_at && now()->gt($invitation->expire_at)) {
            throw new \Exception(__('Your access code has expired.'));
        }

        return $invitation;
    }

    public function access(string $accessCode): PrivateBetaInvitation
    {
        $invitation = $this->checkAccessCode($accessCode);

        $invitation->num_requests += 1;
        $invitation->last_access_at = now();
        $invitation->save();

        return $invitation;
    }

}

4. Make the Routes, the View and the Controller

The Routes

Let's add 2 routes:

  1. GET /private-beta (named: private-beta.index)

  2. POST /private-beta/access (named: private-beta.access)

// routes/web.php

use App\Http\Controllers\PrivateBetaController;

// ...
Route::get('/private-beta', [PrivateBetaController::class, 'index'])
    ->name('private-beta.index');

Route::post('/private-beta/access', [PrivateBetaController::class, 'access'])
    ->middleware('throttle:5,1')
    ->name('private-beta.access');

Note the 'throttle:5,1': you cannot attempt to access more than 5 times per minute.

The View

Make the View private-beta.index:

php artisan make:view private-beta.index

Then the content with a form pointing to the route named private-beta.access:

<!-- resources/views/private-beta/index.blade.php -->

<x-guest-layout>
    <x-auth-session-status class="mb-4" :status="session('status')" />

    <h1 class="text-center text-4xl font-extrabold leading-none tracking-tight text-gray-600 dark:text-white">
        @lang('Private Beta')
    </h1>

    <p class="mt-3 text-center text-gray-700 dark:text-white">
        @lang('This app is only open to users with an access code.')
    </p>

    <hr class="my-6">

    @if (! $isIpWhitelisted)
    <form method="POST" action="{{ route('private-beta.access') }}">
        @csrf

        <div>
            <x-input-label for="access_code" :value="__('Access Code') . ':'" class="sm:text-lg sm:text-blue-700" />
            <x-text-input id="access_code" class="block mt-1 w-full"
                type="text" name="access_code" :value="old('access_code', $currentAccessCode ?? null)"
                required autofocus />
            <x-input-error :messages="$errors->get('access_code')" class="mt-2" />
        </div>

        <div class="mt-4">
            <x-primary-button class="text-center w-full sm:block sm:text-lg">
                @lang('Enter Private Beta')
            </x-primary-button>
        </div>
    </form>
    @else
    <p class="text-center text-green-600 font-bold">
        @lang('Your IP is Whitelisted!')
    </p>
    @endif
</x-guest-layout>

Here, we will use Blade and assume that the Breeze Starter Kit is installed, but you can create the view however you like.


The Controller

Now, let's add the Controller PrivateBetaController with 2 methods:

  1. index() renders the view

  2. access() receives and validates the access code and, when it is correct, sends a cookie to the client for the next requests.

php artisan make:controller PrivateBetaController

The Controller:

// app/Http/Controllers/PrivateBetaController.php

namespace App\Http\Controllers;

use App\Services\PrivateBetaService;
use Illuminate\Http\Request;

class PrivateBetaController extends Controller
{

    public function __construct(
        protected PrivateBetaService $privateBetaService
    ) {}

    public function index(Request $request)
    {
        if (! $this->privateBetaService->isEnabled()) {
            return back();
        }

        $isIpWhitelisted = $this->privateBetaService->isIpWhitelisted($request->ip());
        $currentAccessCode = $request->cookie('private_beta_access_code');

        return view('private-beta.index', compact('isIpWhitelisted', 'currentAccessCode'));
    }

    public function access(Request $request)
    {
        if (! $this->privateBetaService->isEnabled()) {
            return back();
        }

        $request->validate([
            'access_code' => 'required',
        ]);

        try {
            $this->privateBetaService->checkAccessCode($request->access_code);
        } catch (\Exception $e) {
            return back()->withErrors(['access_code' => $e->getMessage()]);
        }

        $cookie = cookie('private_beta_access_code', $request->access_code);

        return redirect()
            ->intended() // <-- this is where the user originally wanted to go
            ->withCookie($cookie);
    }
}

5. Make the Middleware: This is where the work happens

Let's make the Middleware with the name PrivateBetaMiddleware:

php artisan make:middleware PrivateBetaMiddleware

The Service class implementation:

<?php

namespace App\Http\Middleware;

use App\Services\PrivateBetaService;
// use ...

class PrivateBetaMiddleware
{

    public function __construct(
        protected PrivateBetaService $privateBetaService
    ) {}

    public function handle(Request $request, Closure $next): Response
    {
        if (
            ! $this->isCheckPassed($request)
            && ! $request->route()?->named('private-beta.*')
        ) {
            return redirect()
                ->setIntendedUrl(url()->current()) 
                // ^ here is where the user will be redirect back
                ->route('private-beta.index');
        }

        return $next($request);
    }

    public function isCheckPassed(Request $request): bool
    {
        if (! $this->privateBetaService->isEnabled()) {
            return true;
        }

        if ($this->privateBetaService->isIpWhitelisted($request->ip())) {
            return true;
        }

        $cookieAccessCode = $request->cookie('private_beta_access_code');
        if (blank($cookieAccessCode)) {
            return false;
        }

        try {
            $this->privateBetaService->access($cookieAccessCode);
            return true;
        } catch (\Exception $e) {
            return false;
        }
    }
}

Now, we add the middleware to the 'web' group of App\Http\Kernel class. In this way, it will be applied to each web request.

// app/Http/Kernel.php

// ...
class Kernel extends HttpKernel
{
    // ...

    protected $middlewareGroups = [
        'web' => [
            // ...

            \App\Http\Middleware\PrivateBetaMiddleware::class,
        ],
    ];

    // ...
}

6. Check that everything works!

For simplicity, let's create a PrivateBetaInvitation Model record directly via Tinker.

IMPORTANT: status must be set to 'active'

Create record of Model PrivateBetaInvitation

Now, let's open the app in the browser. If everything is ok, you will be redirected to the /private-beta page.

In a first test, we enter the incorrect code "WRONGCODE" and verify that the error message is displayed as we expect:

Test: Put a wrong Access Code

Finally, it's time to put the correct code “JUSTATEST":

Test: Put a correct Access Code

Boom! It works!

Let's open browser DevTools and check that the cookie is set as we expect:

Check in browser DevTools

Back to Tinker, fetch again the invitation record and check if num_requests and last_access_at are changed:

Check if data is changed in the Model

Great!


Next Steps

Next Steps

What is missing?

Obviously, to manage a private beta it is not enough to have an access point but, at a minimum, you need to be able to manage requests from new testers and send them access codes via email, in order to simplify access as much as possible.

All these operations can be automated. This makes sense to do when the numbers are not manageable manually. Perhaps we will address these aspects in a future specific tutorial.


✸ Enjoy your coding!

If you liked this post, don't forget to add your Subscribe to my newsletter!
0
Subscribe to my newsletter

Read articles from Tony Joe directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tony Joe
Tony Joe

Senior Dev. Clean code lover and therefore lover of Laravel. Best friend of deep work & focusing. Interested in critical thinking, self improvement, personal finance and rock'🤟'roll! 📍 I’m curious. I read a lot, listen, study, analyze. But I can’t stand fanaticism: I don’t like chasing after the last-minute framework all the time.