Laravel Auth: Make Registration Invitation-Only

Laravel comes with great Auth out-of-the-box. But some projects don’t allow public registration, they send invitation links instead. This article will show you how to implement it.

Step 1. Invitations: DB Table and Model

To store our invitations, we will have a separate DB table invitations, with three fields: email, invitation_token (will be generated randomly) and registered_at (when invitation actually converted to user).

Schema::create('invitations', function (Blueprint $table) {
    $table->increments('id');
    $table->string('email')->unique();
    $table->string('invitation_token', 32)->unique()->nullable();
    $table->timestamp('registered_at')->nullable();
    $table->timestamps();
});

And here’s the app/Invitation.php model:

class Invitation extends Model
{
    protected $fillable = [
        'email', 'invitation_token', 'registered_at',
    ];
}

Step 2. Requesting Invitation

There are various ways to implement this, but we will assume that users can request an invite, by entering their email.

We need one route in routes/web.php file:

Route::get('register/request', 'Auth\RegisterController@requestInvitation')->name('requestInvitation');

Change in app/Http/Controllers/Auth/RegisterController.php are minimal – one simple method to show the form:

public function requestInvitation() {
    return view('auth.request');
}

Blade file resources/views/auth/request.blade.php is really similar to login.blade.php, just different POST action for the form.

@extends('layouts.app')

@section('content')
    <div class="container">
        <div class="row">
            <div class="col-md-8 col-md-offset-2">
                @if (session('error'))
                    <div class="alert alert-danger">
                        <p>{{ session('error') }}</p>
                    </div>
                @endif

                @if (session('success'))
                    <div class="alert alert-success">
                        <p>{{ session('success') }}</p>
                    </div>
                @endif

                <div class="panel panel-default">
                    <div class="panel-heading">Requesting Invitation</div>

                    <div class="panel-body">
                        <p>{{ config('app.name') }} is a closed community. You must have an invitation link to register. You can request your link below.</p>

                        <form class="form-horizontal" method="POST" action="{{ route('storeInvitation') }}">
                            {{ csrf_field() }}

                            <div class="form-group{{ $errors->has('email') ? ' has-error' : '' }}">
                                <label for="email" class="col-md-4 control-label">E-Mail Address</label>

                                <div class="col-md-6">
                                    <input id="email" type="email" class="form-control" name="email" value="{{ old('email') }}" required autofocus>

                                    @if ($errors->has('email'))
                                        <span class="help-block">
                                            <strong>{{ $errors->first('email') }}</strong>
                                        </span>
                                    @endif
                                </div>
                            </div>

                            <div class="form-group">
                                <div class="col-md-8 col-md-offset-4">
                                    <button type="submit" class="btn btn-primary">
                                        Request An Invitation
                                    </button>

                                    <a class="btn btn-link" href="{{ route('login') }}">
                                        Already Have An Account?
                                    </a>
                                </div>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
@endsection

The main part is probably POST to this:

<form class="form-horizontal" method="POST" action="{{ route('storeInvitation') }}">

Let’s create this route by adding another line into routes/web.php file:

Route::post('invitations', 'InvitationsController@store')->middleware('guest')->name('storeInvitation');

Next, we create the Controller mentioned in the route. app/Http/Controllers/InvitationsController will look like this:

public function store(StoreInvitationRequest $request)
{
    $invitation = new Invitation($request->all());
    $invitation->generateInvitationToken();
    $invitation->save();

    return redirect()->route('requestInvitation')
        ->with('success', 'Invitation to register successfully requested. Please wait for registration link.');
}

There are a few “hidden” things here. First, Form Request file for validation – app/Http/Requests/StoreInvitationRequest.php with these methods:

/**
 * Get the validation rules that apply to the request.
 *
 * @return array
 */
public function rules()
{
    return [
        'email' => 'required|email|unique:invitations'
    ];
}

/**
 * Custom error messages.
 *
 * @return array
 */
public function messages()
{
    return [
        'email.unique' => 'Invitation with this email address already requested.'
    ];
}

Basically, we’re restricting invitations to one only per unique email.

Finally, we need to generate random invitation token, and for that you can implement any logic you want, I’ve chosen a method in the model app/Invitation.php:

public function generateInvitationToken() {
    $this->invitation_token = substr(md5(rand(0, 9) . $this->email . time()), 0, 32);
}

Notice: Yes, the encoding algorithm is pretty random, you can use something like UUID or anything you prefer.

I will remind you how new invitation is stored from Controller:

$invitation = new Invitation($request->all());
$invitation->generateInvitationToken();
$invitation->save();

Step 3. Viewing Invitations from Administrator

This one is pretty simple – we just need to view all the invitations in a table, and administrator will copy-paste the links and send them wherever they like – via email or Slack etc.

/**
 * Invitations group with auth middleware.
 * Even though we only have one route currently, the route group is for future updates.
 */
Route::group([
    'middleware' => ['auth', 'admin'],
    'prefix' => 'invitations'
], function() {
    Route::get('/', 'InvitationsController@index')->name('showInvitations');
});

So, URL /invitations will show the table we want. Here we use a new Middleware we called ‘admin’. Here’s app/Http/Middleware/IsAdmin.php:

class IsAdmin
{
    public function handle($request, Closure $next)
    {
        if (!auth()->check() || auth()->user()->email != 'admin@admin.com') {
            return redirect(route('home'));
        }

        return $next($request);
    }
}

Notice: For simplicity, we’re just checking user’s email to be admin@admin.com, but in real projects you probably want to implement roles/permissions in Laravel or with a package like Spatie Laravel Permissions.

We also need to register this middleware in app/Http/Kernel.php – see last line in this array, we assign a name ‘admin’ which we used earlier in routes:

protected $routeMiddleware = [
    'auth' => \Illuminate\Auth\Middleware\Authenticate::class,
    'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
    'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
    'can' => \Illuminate\Auth\Middleware\Authorize::class,
    'guest' => \App\Http\Middleware\RedirectIfAuthenticated::class,
    'throttle' => \Illuminate\Routing\Middleware\ThrottleRequests::class,
    'admin' => \App\Http\Middleware\IsAdmin::class,
];

Now, let’s get to viewing our invitations table. It’s pretty simple, here’s app/Http/Controllers/InvitationsController.php:

public function index()
{
    $invitations = Invitation::where('registered_at', null)->orderBy('created_at', 'desc')->get();
    return view('invitations.index', compact('invitations'));
}

And resources/views/invitations/index.blade.php:

@extends('layouts.app')

@section('content')
    <div class="container">
        <div class="page-header" style="margin-top: 0">
            <h1>Invitation Requests</h1>
        </div>
        <div class="panel panel-default" style="margin-top: 20px">
            <div class="panel-heading">Pending Requests <span class="badge">{{ count($invitations) }}</span></div>
            <div class="panel-body" style="padding: 0;">
                @if (!empty($invitations))
                    <table class="table table-responsive table-striped" style="margin-bottom: 0">
                        <thead>
                            <tr>
                                <th>Email</th>
                                <th>Created At</th>
                                <th>Invitation Link</th>
                            </tr>
                        </thead>
                        <tbody>
                            @foreach ($invitations as $invitation)
                                <tr>
                                    <td><a href="mailto:{{ $invitation->email }}">{{ $invitation->email }}</a></td>
                                    <td>{{ $invitation->created_at }}</td>
                                    <td>
                                        <kbd>{{ $invitation->getLink() }}</kbd>
                                    </td>
                                </tr>
                            @endforeach
                        </tbody>
                    </table>
                @else
                    <p>No invitation requests!</p>
                @endif
            </div>
        </div>
    </div>
@endsection

The main thing here is to show the invitation link:

<kbd>{{ $invitation->getLink() }}</kbd>

What is getLink()? It’s a method inside app/Invitation.php:

public function getLink() {
    return urldecode(route('register') . '?invitation_token=' . $this->invitation_token);
}

In other words, we will use the same /register URL but will accept it only with ?invitation_token parameter. This is our next step.


Step 4. Processing Invitation Links

Now, let’s say someone has got an invitation link: yourdomain.com/register?invitation_token=XXXXXXXXXXXX. How to process it?

First, we will override the app/Http/Controllers/Auth/RegisterController.php and method showRegistrationForm():

public function showRegistrationForm(Request $request)
{
    $invitation_token = $request->get('invitation_token');
    $invitation = Invitation::where('invitation_token', $invitation_token)->firstOrFail();
    $email = $invitation->email;

    return view('auth.register', compact('email'));
}

Next, in registration form we will disable email field from changing and instead add it as input hidden field – in resources/views/auth/register.blade.php:

<input type="email" class="form-control disabled" value="{{ $email }}" disabled>
<input id="email" type="hidden" class="form-control disabled" name="email" value="{{ $email }}">

Finally, we need to restrict this URL from being accessed without invitation_token parameter. For this, we create another middleware, in app/Http/Middleware/HasInvitation.php:

namespace App\Http\Middleware;

use App\Invitation;
use Closure;
use Illuminate\Database\Eloquent\ModelNotFoundException;

class HasInvitation
{

    public function handle($request, Closure $next)
    {
        /**
         * Only for GET requests. Otherwise, this middleware will block our registration.
         */
        if ($request->isMethod('get')) {

            /**
             * No token = Goodbye.
             */
            if (!$request->has('invitation_token')) {
                return redirect(route('requestInvitation'));
            }

            $invitation_token = $request->get('invitation_token');

            /**
             * Lets try to find invitation by its token.
             * If failed -> return to request page with error.
             */
            try {
                $invitation = Invitation::where('invitation_token', $invitation_token)->firstOrFail();
            } catch (ModelNotFoundException $e) {
                return redirect(route('requestInvitation'))
                    ->with('error', 'Wrong invitation token! Please check your URL.');
            }

            /**
             * Let's check if users already registered.
             * If yes -> redirect to login with error.
             */
            if (!is_null($invitation->registered_at)) {
                return redirect(route('login'))->with('error', 'The invitation link has already been used.');
            }
        }

        return $next($request);
    }
}

There are a few more things we check there – whether the link was already used, or whether invitation_token is invalid.

Finally, in our routes/web.php we override default registration route with this one:

Route::get('register', 'Auth\RegisterController@showRegistrationForm')
  ->name('register')
  ->middleware('hasInvitation');

Aaaaaand, that’s it. With these steps, you have an invite-only registration system.

Repository for this project is available here: https://github.com/LaravelDaily/Laravel-Auth-Invitations

Like our articles?
Check out our Laravel online courses!

2 COMMENTS

  1. This worked very well for my project, however I have one quick question: how can I either delete the invitation from the database once it has been used to register a user, or at least add something to the ‘registered_at’ column so that I can determine which links have been used and which have not? Once again, thank you for the great tutorial!

LEAVE A REPLY

Please enter your comment!
Please enter your name here