Courses

Laravel 11: Small Reservation Project Step-By-Step

After the First Client Review

After making MVP in the last lessons, we sent the result for a client review. Here's one point of feedback they gave us:

All works well, but why does creating users involve adding passwords for them manually? Do you want me to make those passwords and send them via email? It's unsafe. Please build the invitation system with randomized links so the company owners and guides can create their passwords themselves.

Good point. So... It seems like we did not discuss the user creation flow enough, so now we need to make changes (the lesson below) and discuss potential delays/costs with the client. Lesson learned for next time - discuss how exactly features should work in more detail upfront.

Here's the list of topics that we'll cover below:

  • Sending an email to the invited user.
  • Use the invitation link to register the user to the correct company and role.
  • Rewriting old tests and writing new ones.

Sending Invitation Mail

So, first, instead of creating the user right away, let's send an invitation email. They won't be the system users until they register with their password.

So, we will create a separate Model called UserInvitation where we will keep the email, invitation token, company ID, and role ID.

php artisan make:model UserInvitation -m

database/migrations/xxxx_create_intivations_table.php:

public function up(): void
{
Schema::create('user_invitations', function (Blueprint $table) {
$table->increments('id');
$table->string('email')->unique();
$table->string('token', 36)->unique()->nullable();
$table->timestamp('registered_at')->nullable();
$table->foreignId('company_id')->constrained()->cascadeOnDelete();
$table->foreignId('role_id')->constrained()->cascadeOnDelete();
$table->timestamps();
});
}

app/Models/UserInvitation.php:

class UserInvitation extends Model
{
protected $fillable = [
'email',
'token',
'registered_at',
'company_id',
'role_id',
];
}

Next, we must change the form for Company Owner and Guides CRUD. We won't need the name and passwords.

resources/views/companies/users/create.blade.php:

//
@csrf
 
<div> {{-- }}
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{ old('name') }}" type="text" class="block mt-1 w-full" />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
 
<div class="mt-4"> {{-- }}
<div> {{-- }}
<x-input-label for="email" value="Email" />
<x-text-input id="email" name="email" value="{{ old('email') }}" type="text" class="block mt-1 w-full" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
 
<div class="mt-4"> {{-- }}
<x-input-label for="password" value="Password" />
<x-text-input id="password" name="password" value="{{ old('password') }}" type="password" class="block mt-1 w-full" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div> {{-- }}
 
<div class="mt-4">
<x-primary-button>
Save {{-- }}
Send Invitation {{-- }}
</x-primary-button>
</div>
//

resources/views/companies/guides/create.blade.php:

//
@csrf
 
<div> {{-- }}
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{ old('name') }}" type="text" class="block mt-1 w-full" />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
 
<div class="mt-4"> {{-- }}
<div> {{-- }}
<x-input-label for="email" value="Email" />
<x-text-input id="email" name="email" value="{{ old('email') }}" type="text" class="block mt-1 w-full" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
 
<div class="mt-4"> {{-- }}
<x-input-label for="password" value="Password" />
<x-text-input id="password" name="password" value="{{ old('password') }}" type="password" class="block mt-1 w-full" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div> {{-- }}
 
<div class="mt-4">
<x-primary-button>
Save {{-- }}
Send Invitation {{-- }}
</x-primary-button>
</div>
//

resources/views/companies/users/edit.blade.php:

//
@csrf
 
<div> {{-- }}
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{ old('name') }}" type="text" class="block mt-1 w-full" />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
 
<div class="mt-4"> {{-- }}
<div> {{-- }}
<x-input-label for="email" value="Email" />
<x-text-input id="email" name="email" value="{{ old('email') }}" type="text" class="block mt-1 w-full" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
 
<div class="mt-4"> {{-- }}
<x-input-label for="password" value="Password" />
<x-text-input id="password" name="password" value="{{ old('password') }}" type="password" class="block mt-1 w-full" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div> {{-- }}
 
<div class="mt-4">
<x-primary-button>
Save {{-- }}
Send Invitation {{-- }}
</x-primary-button>
</div>
//

resources/views/companies/guides/edit.blade.php:

//
@csrf
 
<div> {{-- }}
<x-input-label for="name" value="Name" />
<x-text-input id="name" name="name" value="{{ old('name') }}" type="text" class="block mt-1 w-full" />
<x-input-error :messages="$errors->get('name')" class="mt-2" />
</div>
 
<div class="mt-4"> {{-- }}
<div> {{-- }}
<x-input-label for="email" value="Email" />
<x-text-input id="email" name="email" value="{{ old('email') }}" type="text" class="block mt-1 w-full" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
 
<div class="mt-4"> {{-- }}
<x-input-label for="password" value="Password" />
<x-text-input id="password" name="password" value="{{ old('password') }}" type="password" class="block mt-1 w-full" />
<x-input-error :messages="$errors->get('password')" class="mt-2" />
</div> {{-- }}
 
<div class="mt-4">
<x-primary-button>
Save {{-- }}
Send Invitation {{-- }}
</x-primary-button>
</div>
//

send invitation form

Also, it means we need only the email in the Form Request. The email must also check uniqueness in the user_invitations table instead of the users table. And we will change the validation message.

app/Http/Requests/StoreUserRequest.php:

class StoreGuideRequest extends FormRequest
{
// ...
 
public function rules(): array
{
return [
'name' => ['required', 'string'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', Rules\Password::defaults()],
'email' => ['required', 'email', 'unique:user_invitations,email'],
];
}
 
public function messages(): array
{
return [
'email.unique' => 'Invitation with this email address already requested.'
];
}
}

app/Http/Requests/StoreGuideRequest.php:

class StoreGuideRequest extends FormRequest
{
// ...
 
public function rules(): array
{
return [
'name' => ['required', 'string'],
'email' => ['required', 'email', 'unique:users,email'],
'password' => ['required', Rules\Password::defaults()],
'email' => ['required', 'email', 'unique:user_invitations,email'],
];
}
 
public function messages(): array
{
return [
'email.unique' => 'Invitation with this email address already requested.'
];
}
}

Now, we need to create the Mail.

php artisan make:mail RegistrationInvite --markdown=emails.invitation

The Mail will accept the invitation, which we will create in the Controller later before sending the Mail. And to the markdown, we need to pass the invitation URL.

app/Mail/RegistrationInvite.php:

use App\Models\UserInvitation;
 
class RegistrationInvite extends Mailable
{
use Queueable, SerializesModels;
 
public function __construct(private readonly UserInvitation $invitation)
{}
 
public function envelope(): Envelope
{
return new Envelope(
subject: 'Invitation',
);
}
 
public function content(): Content
{
return new Content(
markdown: 'emails.invitation',
with: [
'inviteUrl' => urldecode(route('register') . '?invitation_token=' . $this->invitation->token),
]
);
}
}

And the Mail message can look like this.

<x-mail::message>
# You Have Been Invited
 
You have been invited to the {{ config('app.name') }}
 
<x-mail::button :url="$inviteUrl">
Register
</x-mail::button>
 
Thanks,<br>
{{ config('app.name') }}
</x-mail::message>

Next, in the Controller, instead of creating a user, we send the invitation.

app/Http/Controllers/CompanyUserController.php:

use App\Models\UserInvitation;
use Illuminate\Support\Str;
use App\Mail\UserRegistrationInvite;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Gate;
 
class CompanyUserController extends Controller
{
// ...
 
public function store(StoreUserRequest $request, Company $company)
{
Gate::authorize('create', $company);
 
$invitation = UserInvitation::create([
'email' => $request->input('email'),
'token' => Str::uuid(),
'company_id' => $company->id,
'role_id' => Role::COMPANY_OWNER->value,
]);
 
Mail::to($request->input('email'))->send(new UserRegistrationInvite($invitation));
 
return to_route('companies.users.index', $company);
}
 
// ...
}

app/Http/Controllers/CompanyGuideController.php:

use App\Models\UserInvitation;
use Illuminate\Support\Str;
use App\Mail\UserRegistrationInvite;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Gate;
 
class CompanyGuideController extends Controller
{
// ...
 
public function store(StoreGuideRequest $request, Company $company)
{
Gate::authorize('create', $company);
 
$invitation = UserInvitation::create([
'email' => $request->input('email'),
'token' => Str::uuid(),
'company_id' => $company->id,
'role_id' => Role::GUIDE->value,
]);
 
Mail::to($request->input('email'))->send(new UserRegistrationInvite($invitation));
 
return to_route('companies.guides.index', $company);
}
 
// ...
}

And the invitation email is now sent.

invitation email


Registering User with Invitation

Now that users can receive the invitation email, we need to make the registration part work. This can be done in a couple of ways, like adding a hidden field with the token, but I chose to do it with Session.

First, in the RegisteredUserController of Laravel Breeze, let's put the token into a Session and auto-fill the email field.

app/Http/Controllers/RegisteredUserController.php:

use App\Models\UserInvitation;
 
class RegisteredUserController extends Controller
{
public function create(Request $request): View
{
$email = null;
 
if ($request->has('invitation_token')) {
$token = $request->input('invitation_token');
 
session()->put('invitation_token', $token);
 
$invitation = UserInvitation::where('token', $token)
->whereNull('registered_at')
->firstOrFail();
 
$email = $invitation->email;
}
 
return view('auth.register', compact('email'));
}
 
// ...
}

resources/views/auth/register.blade.php:

// ...
<!-- Email Address -->
<div class="mt-4">
<x-input-label for="email" :value="__('Email')" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email')" required autocomplete="username" />
<x-text-input id="email" class="block mt-1 w-full" type="email" name="email" :value="old('email', $email)" required autocomplete="username" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
// ...

email auto filled from invitation

All that's left is to check if the Session has the invitation_token key. If it does, get the invitation where the token and entered email match, and registered_at is null.

And then, when creating the user, set the correct company_id and role_id.

app/Http/Controllers/RegisteredUserController.php:

use Illuminate\Validation\ValidationException;
 
class RegisteredUserController extends Controller
{
// ...
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
]);
 
if ($request->session()->get('invitation_token')) {
$invitation = UserInvitation::where('token', $request->session()->get('invitation_token'))
->where('email', $request->email)
->whereNull('registered_at')
->firstOr(fn() => throw ValidationException::withMessages(['invitation' => 'Invitation link does not match the email']));
 
$role = $invitation->role_id;
$company = $invitation->company_id;
 
$invitation->update(['registered_at' => now()]);
}
 
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
'role_id' => Role::CUSTOMER->value,
'role_id' => $role ?? Role::CUSTOMER->value,
'company_id' => $company ?? null,
]);
 
event(new Registered($user));
 
Auth::login($user);
 
return redirect(RouteServiceProvider::HOME);
}
}

If we don't find the invitation, we throw the Validation Exception. Let's show the validation message at the start of the registration form.

resources/views/auth/register.blade.php:

<x-guest-layout>
<form method="POST" action="{{ route('register') }}">
@csrf
 
<x-input-error :messages="$errors->get('invitation')" class="mt-2" />
 
// ...

invitation error


Tests

After changing the user creation logic, we must also change the tests and add a new one for the registration. Instead of testing that user was created, we need to check that the invitation was created with the right company_id and role_id and that the Mail was sent.

We will create new tests in the CompanyUserTest instead of the old test_admin_can_create_user_for_a_company and test_company_owner_can_create_user_to_his_company.

tests/Feature/CompanyUserTest.php:

use App\Mail\UserRegistrationInvite;
use Illuminate\Support\Facades\Mail;
 
class CompanyUserTest extends TestCase
{
use RefreshDatabase;
 
// ...
 
public function test_admin_can_send_invite_to_user_for_a_company()
{
Mail::fake();
 
$company = Company::factory()->create();
$user = User::factory()->admin()->create();
 
$response = $this->actingAs($user)->post(route('companies.users.store', $company->id), [
'email' => '[email protected]',
]);
 
Mail::assertSent(UserRegistrationInvite::class);
 
$response->assertRedirect(route('companies.users.index', $company->id));
 
$this->assertDatabaseHas('user_invitations', [
'email' => '[email protected]',
'registered_at' => null,
'company_id' => $company->id,
'role_id' => Role::COMPANY_OWNER->value,
]);
}
 
public function test_invitation_can_be_sent_only_once_for_user()
{
$company = Company::factory()->create();
$user = User::factory()->admin()->create();
 
$this->actingAs($user)->post(route('companies.users.store', $company->id), [
'email' => '[email protected]',
]);
 
$response = $this->actingAs($user)->post(route('companies.users.store', $company->id), [
'email' => '[email protected]',
]);
 
$response->assertInvalid(['email' => 'Invitation with this email address already requested.']);
}
 
// ...
 
public function test_company_owner_can_send_invite_to_user()
{
Mail::fake();
 
$company = Company::factory()->create();
$user = User::factory()->admin()->create();
 
$response = $this->actingAs($user)->post(route('companies.users.store', $company->id), [
'email' => '[email protected]',
]);
 
Mail::assertSent(UserRegistrationInvite::class);
 
$response->assertRedirect(route('companies.users.index', $company->id));
 
$this->assertDatabaseHas('user_invitations', [
'email' => '[email protected]',
'registered_at' => null,
'company_id' => $company->id,
'role_id' => Role::COMPANY_OWNER->value,
]);
}
 
// ...
}

The same goes for the CompanyGuideTest test, a new one instead of test_company_owner_can_create_guide_to_his_company.

tests/Feature/CompanyGuideTest.php:

use App\Mail\UserRegistrationInvite;
use Illuminate\Support\Facades\Mail;
 
class CompanyGuideTest extends TestCase
{
use RefreshDatabase;
 
// ...
 
public function test_company_owner_can_send_invite_to_guide_to_his_company()
{
Mail::fake();
 
$company = Company::factory()->create();
$user = User::factory()->admin()->create();
 
$response = $this->actingAs($user)->post(route('companies.guides.store', $company->id), [
'email' => '[email protected]',
]);
 
Mail::assertSent(UserRegistrationInvite::class);
 
$response->assertRedirect(route('companies.guides.index', $company->id));
 
$this->assertDatabaseHas('user_invitations', [
'email' => '[email protected]',
'registered_at' => null,
'company_id' => $company->id,
'role_id' => Role::GUIDE->value,
]);
}
 
public function test_invitation_can_be_sent_only_once_for_user()
{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company]);
 
$this->actingAs($user)->post(route('companies.guides.store', $company->id), [
'email' => '[email protected]',
]);
 
$response = $this->actingAs($user)->post(route('companies.guides.store', $company->id), [
'email' => '[email protected]',
]);
 
$response->assertInvalid(['email' => 'Invitation with this email address already requested.']);
}
 
// ...
}

That's it for the invitation tests. Now let's add new tests for the registration. We will test that the user is registered with the right company and get the correct role.

tests/Feature/Auth/RegistrationTest.php:

use App\Enums\Role;
use App\Models\User;
use App\Models\Company;
use App\Models\UserInvitation;
use Illuminate\Support\Facades\Auth;
 
class RegistrationTest extends TestCase
{
// ...
 
public function test_user_can_register_with_token_for_company_owner_role()
{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company->id]);
 
$this->actingAs($user)->post(route('companies.users.store', $company->id), [
'email' => '[email protected]',
]);
 
$invitation = UserInvitation::where('email', '[email protected]')->first();
 
Auth::logout();
 
$response = $this->withSession(['invitation_token' => $invitation->token])->post('/register', [
'name' => 'Test User',
'email' => '[email protected]',
'password' => 'password',
'password_confirmation' => 'password',
]);
 
$this->assertDatabaseHas('users', [
'name' => 'Test User',
'email' => '[email protected]',
'company_id' => $company->id,
'role_id' => Role::COMPANY_OWNER->value,
]);
 
$this->assertAuthenticated();
 
$response->assertRedirect(route('dashboard', absolute: false));
}
 
public function test_user_can_register_with_token_for_guide_role()
{
$company = Company::factory()->create();
$user = User::factory()->companyOwner()->create(['company_id' => $company->id]);
 
$this->actingAs($user)->post(route('companies.guides.store', $company->id), [
'email' => '[email protected]',
]);
 
$invitation = UserInvitation::where('email', '[email protected]')->first();
 
Auth::logout();
 
$response = $this->withSession(['invitation_token' => $invitation->token])->post('/register', [
'name' => 'Test User',
'email' => '[email protected]',
'password' => 'password',
'password_confirmation' => 'password',
]);
 
$this->assertDatabaseHas('users', [
'name' => 'Test User',
'email' => '[email protected]',
'company_id' => $company->id,
'role_id' => Role::GUIDE->value,
]);
 
$this->assertAuthenticated();
 
$response->assertRedirect(route('dashboard', absolute: false));
}
}

Let's check all the tests. Great, they are all green!

after client review tests

avatar

Hello. Some questions / corrections:

  1. app/Http/Controllers/CompanyUser (Guide) Controller.php: RegistrationInvite but not UserRegistrationInvite.
  2. app/Http/Controllers/Auth/RegisteredUserController.php: but not app/Http/Controllers/RegisteredUserController.php:
  3. Actually do we need edit scenario for users (guides)? For what we may edit an email in this way? If not we need to change / remove "update requests files" and it's methods in controllers.
avatar
You can use Markdown
avatar

Hello. I think in the function test_company_owner_can_send_invite_to_user the line $user = User::factory()->admin()->create(); should be $user = User::factory()->companyOwner()->create(); is this correct? Thanks.

avatar

same in the function test_company_owner_can_send_invite_to_guide_to_his_company

avatar

Thanks. Updated the lesson.

avatar
You can use Markdown
avatar

Hello. I think in the function test_company_owner_can_send_invite_to_user the line

$user = User::factory()->companyOwner()->create(); should be $user = User::factory()->companyOwner()->create(['company_id' => $company->id]);

if not, then in this line, the user doesn't have the same company $response->assertRedirect(route('companies.users.index', $company->id));

avatar

In this case it doesn't matter because we test different thing. But for consistency yes it would be better. Updated the lesson.

avatar

Yeah i noticed same thing i had to switch it like this for company owner

    $response = $this->actingAs($companyOwner)->post(route('companies.users.store', $company->id), [
        'email' => 'test@test.com'
    ]);

    $response->assertRedirect(route('companies.users.index', $company->id));

    // checks if mailable of type UserRegistrationInvite was sent from above line of code
    Mail::assertSent(UserRegistrationInvite::class);
avatar
You can use Markdown
avatar

"And the invitation email is now sent..." But I'm affraid I get an error here: Connection could not be established with host "mailpit:1025": stream_socket_client(): php_network_getaddresses: getaddrinfo for mailpit failed: Host is onbekend. / Is it necessary to register with mailgun.org? Can this be the reason for the failure? Thank you for the reply!

avatar

You need to use some email provider to send email, yes. It could be Mailgun or similar for real emails, or Mailpit/Mailtrap for fake emails. And yes, that's the reason for the failure.

avatar
You can use Markdown
avatar
You can use Markdown