How to handle a Private Beta with access code for your new app in Laravel
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 (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
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
:
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:
// 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
orphp 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:
GET
/private-beta
(named:private-beta.index
)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:
index()
renders the viewaccess()
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'
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:
Finally, it's time to put the correct code “JUSTATEST":
Boom! It works!
Let's open browser DevTools and check that the cookie is set as we expect:
Back to Tinker, fetch again the invitation record and check if num_requests
and last_access_at
are changed:
Great!
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!
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.