Courses

Laravel 12 Multi-Tenancy: All You Need To Know

Tenant Owner: Manage Users - Menu and Permission

Summary of this lesson:
- Adding is_owner boolean to tenant_user pivot table
- Setting owner status during user registration
- Creating Users menu for tenant management
- Implementing Gate authorization for user management

In a few upcoming lessons, let's work on the invitation system.

So, I've just registered, and I want to invite new users to my team/tenant. For that, we will create a new menu item on top called Users where we will manage the invitations and the users who are invited to the team.

For that, we must introduce a concept called "Team Owner" or the person who created the tenant. Again, there are multiple ways how you can do that in the database. We will add an is_owner boolean column to the tenant_user pivot table, which will default to false and set to true during registration.


Database Changes

So, first, let's add a column titled is_owner.

php artisan make:migration "add is owner to tenant user table"

database/migrations/xxx_add_is_owner_to_tenant_user_table.php:

Schema::table('tenant_user', function (Blueprint $table) {
$table->boolean('is_owner')->default(false);
});

app/Models/Tenant.php:

class Tenant extends Model
{
// ...
 
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class);
return $this->belongsToMany(User::class)->withPivot('is_owner');
}
}

app/Models/User.php:

class User extends Authenticatable
{
// ...
 
public function tenants(): BelongsToMany
{
return $this->belongsToMany(Tenant::class);
return $this->belongsToMany(Tenant::class)->withPivot('is_owner');
}
}

Registration: Setting the Owner

Next, when we attach a user to a tenant in the registration Controller, we must set is_owner to true.

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],
'password' => ['required', 'confirmed', Rules\Password::defaults()],
'subdomain' => ['required', 'alpha:ascii', 'unique:'.Tenant::class],
]);
 
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
 
$tenant = Tenant::create([
'name' => $request->name . ' Team',
'subdomain' => $request->subdomain,
]);
$tenant->users()->attach($user);
$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'));
return redirect($tenantDomain . route('dashboard', absolute: false));
}
}

After registering in the database, the is_owner is true.


Manage Tenant Users

Now, with this user who is the owner of a tenant, we should see the page to manage tenant users.

php artisan make:controller UserController

app/Http/Controllers/UserController.php:

use Illuminate\Contracts\View\View;
 
class UserController extends Controller
{
public function index(): View
{
return view('users.index');
}
}

resources/views/users/index.php:

<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Users') }}
</h2>
</x-slot>
 
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<div class="bg-white overflow-hidden shadow-sm sm:rounded-lg">
<div class="p-6 bg-white border-b border-gray-200">
Coming soon.
</div>
</div>
</div>
</div>
</x-app-layout>

routes/web.php:

// ...
 
Route::middleware('auth')->group(function () {
Route::get('tenants/change/{tenantId}', \App\Http\Controllers\TenantController::class)->name('tenants.change');
 
Route::resource('tasks', \App\Http\Controllers\TaskController::class);
Route::resource('projects', \App\Http\Controllers\ProjectController::class);
Route::resource('users', \App\Http\Controllers\UserController::class)->only('index', 'store');
 
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});
 
require __DIR__.'/auth.php';

resources/views/layouts/navigation.blade.php:

// ...
 
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-nav-link>
<x-nav-link :href="route('tasks.index')" :active="request()->routeIs('tasks.*')">
{{ __('Tasks') }}
</x-nav-link>
<x-nav-link :href="route('projects.index')" :active="request()->routeIs('projects.*')">
{{ __('Projects') }}
</x-nav-link>
<x-nav-link :href="route('users.index')" :active="request()->routeIs('users.*')">
{{ __('Users') }}
</x-nav-link>
</div>
 
// ...

In the navigation, we can see the Users menu item and visit this page.


Protecting Page Access

Finally, we must restrict access to this Users page. For that, we will define a Gate. In the Gate, we will check if the user of the current tenant is the owner.

app/Providers/AppServiceProviders.php:

use App\Models\User;
use Illuminate\Support\Facades\Gate;
 
class AppServiceProvider extends ServiceProvider
{
// ...
 
public function boot(): void
{
Gate::define('manage-users', function (User $user) {
return $user->tenants()
->wherePivot('tenant_id', $user->current_tenant_id)
->wherePivot('is_owner', true)
->exists();
});
}
}

Next, we must use this Gate on the Route and navigation.

routes/web.php:

// ...
 
Route::middleware('auth')->group(function () {
Route::get('tenants/change/{tenantId}', \App\Http\Controllers\TenantController::class)->name('tenants.change');
 
Route::resource('tasks', \App\Http\Controllers\TaskController::class);
Route::resource('projects', \App\Http\Controllers\ProjectController::class);
Route::resource('users', \App\Http\Controllers\UserController::class)
->only('index', 'store')
->middleware('can:manage-users');
 
Route::get('/profile', [ProfileController::class, 'edit'])->name('profile.edit');
Route::patch('/profile', [ProfileController::class, 'update'])->name('profile.update');
Route::delete('/profile', [ProfileController::class, 'destroy'])->name('profile.destroy');
});
 
require __DIR__.'/auth.php';

resources/views/layouts/navigation.blade.php:

// ...
 
<!-- Navigation Links -->
<div class="hidden space-x-8 sm:-my-px sm:ms-10 sm:flex">
<x-nav-link :href="route('dashboard')" :active="request()->routeIs('dashboard')">
{{ __('Dashboard') }}
</x-nav-link>
<x-nav-link :href="route('tasks.index')" :active="request()->routeIs('tasks.*')">
{{ __('Tasks') }}
</x-nav-link>
<x-nav-link :href="route('projects.index')" :active="request()->routeIs('projects.*')">
{{ __('Projects') }}
</x-nav-link>
@can('manage-users')
<x-nav-link :href="route('users.index')" :active="request()->routeIs('users.*')">
{{ __('Users') }}
</x-nav-link>
@endcan
</div>
 
// ...

If you tried to access the Users page with a different user, you would get a "403 This Action is Unauthorized" error message.

Previous: "Wildcard" Subdomain for Every Tenant
avatar

I have run complete repository but showing 419 Page Expired. Is there anything missing from my side?

avatar

419 page expired usually means something is wrong with your session storage so it doesn't save session cookies as usual. Sorry hard to "blindly" debug it to tell you what can be wrong.

avatar

How Can I implement this concept of Multitenancy on the Login and Register Routes where the user is not yet exisiting.

If you apply globalscope and let model self::creating populate the db with user()->id, when accessing the login and register routes Laravel seems to fire the Memory overflow error.

PHP Fatal error : Allowed memory size of xxxxxx bytes executed (tried to allocated xxxx bytes) in vendor/laravel/framework/src/Illuminate/Database/Eloquent/builder.php on line 288

PHP Fatal error : Allowed memory size of xxxxxx bytes executed (tried to allocated xxxx bytes) in vendor/laravel/framework/src/Illuminate/Database/Eloquent/DatabaseManager.php on line 91

avatar

In the global scope, the best way is to probably check if (auth()->check()) before applying the scope.

avatar

Hi Povilas, I think you should add current_tenant_id when define Gate 'manage_users'?

Gate::define('manage_users', function(User $user) { $tenantId = $user->current_tenant_id; return $user->tenants()->where('id', $tenantId)->wherePivot('is_owner', true)->exists(); });

avatar

You're probably right, good catch!

avatar
You can use Markdown
avatar
You can use Markdown