How to handle a Private Beta with access code for your new app in Laravel
Tags: laravel, tutorial, webdev, beta
Published: 2023-09-24 18:49:50

**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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/n66qcxui289qednf412s.jpg)

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](#heading-1-make-the-model-and-migration-for-invitations)
    
2. [Add some Config parameters](#heading-2-add-some-config-parameters)
    
3. [Make a Service class](#heading-3-make-a-service-class)
    
4. [Make the Routes, the View and the Controller](#heading-4-make-the-routes-the-view-and-the-controller)
    
5. [Make the Middleware: This is where the work happens](#heading-5-make-the-middleware-this-is-where-the-work-happens)
    
6. [Check that everything works!](#heading-6-check-that-everything-works)
    

---

## 1\. Make the Model and Migration for Invitations <a name="heading-1-make-the-model-and-migration-for-invitations"></a>

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

```bash
php artisan make:model PrivateBetaInvitation --migration
```

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

| Field | Description |
| --- | --- |
| `status` | *values:* **pending**, **waiting**, **active**, **archived** |
| `access_code` | the code that users (testers) will receive and must enter to access the app |
| `expire_at` | *optional* |
| `num_requests` | basic stats |
| `last_access_at` | \- |

The migration:

```php
// 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:

```bash
php artisan migrate
```

The Model `PrivateBetaInvitation`:

```php
// 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 <a name="heading-2-add-some-config-parameters"></a>

Add variables in `.env` file:

```ini
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:

```php
// 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 <a name="heading-3-make-a-service-class"></a>

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:

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

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

```php
// 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:

```php
// 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 <a name="heading-4-make-the-routes-the-view-and-the-controller"></a>

### The Routes

Let's add 2 routes:

1. **GET** `/private-beta` (named: `private-beta.index`)
    
2. **POST** `/private-beta/access` (named: `private-beta.access`)
    

```php
// 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`:

```bash
php artisan make:view private-beta.index
```

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

```xml
<!-- 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**](https://laravel.com/docs/10.x/starter-kits#laravel-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.
    

```bash
php artisan make:controller PrivateBetaController
```

The Controller:

```php
// 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 <a name="heading-5-make-the-middleware-this-is-where-the-work-happens"></a>

Let's make the Middleware with the name `PrivateBetaMiddleware`:

```bash
php artisan make:middleware PrivateBetaMiddleware
```

The Service class implementation:

```php
<?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*.

```php
// app/Http/Kernel.php

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

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

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

    // ...
}
```

---

## 6\. Check that everything works! <a name="heading-6-check-that-everything-works"></a>

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

> **IMPORTANT**: `status` must be set to `'active'`

![Create record of Model PrivateBetaInvitation](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/2hf656vd19ye4ycabqw3.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/qdhxexzl4swhm87taxot.png)


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

![Test: Put a correct Access Code](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0302b8tinx0tb0z58pb3.png)



**Boom! It works!**

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

![Check in browser DevTools](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6paop4at8trm02iflh3h.png)



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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/l9s2bj86by8593vsun2l.png)



*Great!*

---

## Next Steps

![Next Steps](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nond6nh9c2ylksvkpuw6.jpg)


### 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!

&nbsp;

###### If you liked this post, don't forget to add your Follow to my profile!

{% user tonyjoe %}

---

###### *If you want to preview my content, [Subscrive to my Newsletter](https://tonyjoe.dev/newsletter)*!