Courses

Laravel 12 Multi-Tenancy: All You Need To Know

Accept Invitation: Existing User or Register New User

Summary of this lesson:
- Handling existing user invitation acceptance
- Managing tenant switching after acceptance
- Creating registration flow for new users
- Implementing subdomain redirection after acceptance

Let's finish the invitation system by accepting the invitation.

If I'm logged in to another tenant at the moment, I need to:

  1. Accept the invitation.
  2. Attach me to the tenant.
  3. Set the current tenant from the invitation.
  4. Redirect to the invited tenants' dashboard.

app/Http/Controllers/UserController.php:

class UserController extends Controller
{
// ...
 
public function acceptInvitation(string $token)
{
$invitation = Invitation::where('token', $token)
->whereNull('accepted_at')
->firstOrFail();
 
if (auth()->check()) {
$invitation->update(['accepted_at' => now()]);
 
auth()->user()->tenants()->attach($invitation->tenant_id);
 
auth()->user()->update(['current_tenant_id' => $invitation->tenant_id]);
 
$tenantDomain = str_replace('://', '://' . $invitation->tenant->subdomain . '.', config('app.url'));
return redirect($tenantDomain . route('dashboard', absolute: false));
} else {
// redirect to register
}
}
}

Now, if I register with a new user, this user has only its own tenant.

From another user, I send email invitations to the newly registered user.

As you can see, when the invitation is accepted, the user should be redirected to the bbb subdomain tenant and should have two tenants.

We can see that after accepting the invitation, the user is redirected to the correct subdomain. The user now belongs to two tenants. The tenant in bold is active, which is correct in this case.

Good. The first case is working correctly. Now, let's cover the case of registering a fresh user after the invitation.


In the UserController, we only need to redirect the user to the registration page with a token in the URL.

app/Http/Controllers/UserController.php:

class UserController extends Controller
{
// ...
 
public function acceptInvitation(string $token): RedirectResponse
{
$invitation = Invitation::where('token', $token)
->whereNull('accepted_at')
->firstOrFail();
 
if (auth()->check()) {
$invitation->update(['accepted_at' => now()]);
 
auth()->user()->tenants()->attach($invitation->tenant_id);
 
auth()->user()->update(['current_tenant_id' => $invitation->tenant_id]);
 
$tenantDomain = str_replace('://', '://' . $invitation->tenant->subdomain . '.', config('app.url'));
return redirect($tenantDomain . route('dashboard', absolute: false));
} else {
return redirect()->route('register', ['token' => $invitation->token]);
}
}
}

Now, we will pass the email to the register form. We must get that email from the invitation token.

app/Http/Controllers/Auth/RegisteredUserController.php:

use App\Models\Invitation;
 
class RegisteredUserController extends Controller
{
public function create(): View
{
$invitationEmail = null;
 
if (request('token')) {
$invitation = Invitation::where('token', request('token'))
->whereNull('accepted_at')
->firstOrFail();
 
$invitationEmail = $invitation->email;
}
 
return view('auth.register');
return view('auth.register', compact('invitationEmail'));
}
 
// ...
}

We don't need the subdomain in the View because it will already exist. Also, we will disable the email field and set the value to $invitationEmail.

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" :disabled="! is_null($invitationEmail)" />
<x-input-error :messages="$errors->get('email')" class="mt-2" />
</div>
 
<!-- Subdomain -->
@empty($invitationEmail)
<div class="mt-4">
<x-input-label for="subdomain" :value="__('Subdomain')" />
<x-text-input id="subdomain" class="block mt-1 mr-2 w-full" type="text" name="subdomain" :value="old('subdomain')" required />
<x-input-error :messages="$errors->get('subdomain')" class="mt-2" />
</div>
@endempty
 
// ...

We also need the token as a hidden input.

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

// ...
 
@csrf
@empty(! $invitationEmail)
<input type="hidden" value="{{ request('token') }}">
@endempty
 
// ...

If we try to invite with an email that isn't registered already after opening the invitation URL from the email, we are redirected to the tenant's registration page. We don't see the subdomain input in the registration page, and email is disabled.

Finally, we need to register the new user. First, we must change the validation rule from required to sometimes for the email and subdomain inputs.

app/Http/Controllers/Auth/RegisteredUserController.php:

class RegisteredUserController extends Controller
{
// ...
 
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'email' => ['sometimes', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
'subdomain' => ['required', 'alpha:ascii', 'unique:'.Tenant::class],
'subdomain' => ['sometimes', 'alpha:ascii', 'unique:'.Tenant::class],
]);
 
// ...
}
}

Next, we must check the token, and if it's invalid, throw a validation error.

app/Http/Controllers/Auth/RegisteredUserController.php:

class RegisteredUserController extends Controller
{
// ...
 
public function store(Request $request): RedirectResponse
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'email' => ['sometimes', 'string', 'lowercase', 'email', 'max:255', 'unique:'.User::class],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
'subdomain' => ['required', 'alpha:ascii', 'unique:'.Tenant::class],
'subdomain' => ['sometimes', 'alpha:ascii', 'unique:'.Tenant::class],
]);
 
$email = $request->email;
if ($request->has('token')) {
$invitation = Invitation::with('tenant')
->where('token', $request->token)
->whereNull('accepted_at')
->firstOr(function () {
throw ValidationException::withMessages([
'email' => __('Invitation link incorrect'),
]);
});
 
$email = $invitation->email;
}
 
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'email' => $email,
'password' => Hash::make($request->password),
]);
 
// ...
}
}

Then, if we have an invitation, the user should be attached to the existing one instead of creating a tenant. Finally, it is redirected to the correct subdomain.

app/Http/Controllers/Auth/RegisteredUserController.php:

class RegisteredUserController extends Controller
{
// ...
 
public function store(Request $request): RedirectResponse
{
// ...
 
$user = User::create([
'name' => $request->name,
'email' => $email,
'password' => Hash::make($request->password),
]);
 
$subdomain = $request->subdomain;
if ($invitation) {
$invitation->update(['accepted_at' => now()]);
$invitation->tenant->users()->attach($user->id);
$user->update(['current_tenant_id' => $invitation->tenant_id]);
$subdomain = $invitation->tenant->subdomain;
} else {
$tenant = Tenant::create([
'name' => $request->name . ' Team',
'subdomain' => $request->subdomain,
]);
$tenant->users()->attach($user, ['is_owner' => true]);
$user->update(['current_tenant_id' => $tenant->id]);
}
 
event(new Registered($user));
 
Auth::login($user);
 
$tenantDomain = str_replace('://', '://' . $request->subdomain . '.', config('app.url'));
$tenantDomain = str_replace('://', '://' . $subdomain . '.', config('app.url'));
return redirect($tenantDomain . route('dashboard', absolute: false));
}
}

The new user's registration from the invitation link should now work. Now, we have the entire process of seeing the invited users, inviting a new user, and then accepting the invitation as an existing or new user.


We have finished the chapter on single database multi-tenancy without any packages.

In the following sections, we will repeat almost the same things with extra packages and see how they help us.

Previous: Sending Invitation Email and Accept Route
avatar

hi would you be able to do a video on how to handle the invitation system for using archtechx tenancy package with multiple dbs for tenants while this works fine for single db for multiple dbs i think the invitations and users should be stored in tenants db , and have a separate login via tenant.domain.com/login thanks

avatar

Hi Vasile, To be honest, I haven't tried it. Currently I don't have time available to explore this topic, but I will add it to my (already huge) topic list, and maybe in the future I will get to it.

avatar
You can use Markdown
avatar

Great video, As I remember, Laravel has "Signed URLs" feature, which helps to prevent from changing the URL,would it be better if we apply this feature to accept URL in the email?

In addition to "Signed URLs" feature, I think we can also generate a token by using Str::uuid() to make sure it is unique for each invitation email because Str::random() sometimes can generate a duplicate string for two invited different users and thus can cause the potential bug. What do you think?

avatar

Signed URLs are advised to use only for non-crucial features, like unsubscribe from email list. They are not considered secure enough, so I wouldn't probably use it for this use-case.

avatar
You can use Markdown
avatar

Newbie here, I have cloned the repo of this chapter and I try to register a new user. When I post the Register form, I get a 419 indicating csrf is missing. But in resources/views/auth/register.blade.php the @csrf is there.

Additional info: After cloning the repo, I ran

composer install
php artisan key:generate
php artisan migrate
php artisan serve
avatar

419 doesn't necessarily mean that CSRF is missing. Have you provided the correct URL in .env APP_URL? Have you made your storage folders writable for the session store?

avatar

Thanks, now I ran

  • npm install
  • npm run dev
  • chmod 755 -R nameofmyproject/ (being run from one level above the project)
  • chmod -R o+w nameofmyproject/storage (being run from one level above the project)
  • php artisan cache:clear
  • php artisan view:clear
  • php artisn config:clear
  • composer dump-autoload

I am on a Windows 10 machine APP_URL=localhost (also tried APP_URL=localhost:8000)

Still I get 419

avatar

If I try another repo, like LaravelDaily/Laravel-9-Beginners-Admin it works. I just do:

git clone LaravelDaily/Laravel-9-Beginners-Admin myproject
cd myproject
composer install
npm install
npm run dev
create .env
php artisan key:generate
set a name to the database in .env and create it in PHPMyAdmin
php artisan migrate
php artisan serve

And then I set is_admin to 1 in users table. After that I can run both cruds. No 419.

avatar
You can use Markdown
avatar

Thank you for the course. Really great and helped me a lot! I'm not sure, but maybe a few comments here:

resources/views/auth/register.blade.php: < input type="hidden" value="{{ request('token') }}" > in the input is missing the attribute: name="token". < input type="hidden" name="token" value="{{ request('token') }}" >

resources/views/auth/register.blade.php: < x-text-input id="email" :value="old('email')" ... > is missing the $invitationEmail < x-text-input id="email" :value="old('email', $invitationEmail)" ... >

👍 1
avatar
You can use Markdown
avatar
You can use Markdown