Back to Course |
Laravel 11 Multi-Tenancy: All You Need To Know

archtechx / tenancy: Installation, Configuration and Register Tenant

The next package we will check is stancl/tenancy. With this package, you can have single and multi-database tenancy. First, we will take a look at a single database approach.

The starting point is the same CRUD with the Project and Task without any tenancy.


Installation and Configuration

So, first, install the package via composer and then run the tenancy:install command.

composer require stancl/tenancy
php artisan tenancy:install
php artisan migrate

Next, we must add the TenancyServiceProvider.

bootstrap/providers.php:

return [
App\Providers\AppServiceProvider::class,
App\Providers\TenancyServiceProvider::class,
];

Install command created the Migration for the tenants table. Now we must make a Model for that table and replace the content of the Model with the code from the quickstart.

php artisan make:model Tenant

app/Models/Tenant.php:

namespace App\Models;
 
use Stancl\Tenancy\Database\Models\Tenant as BaseTenant;
use Stancl\Tenancy\Contracts\TenantWithDatabase;
use Stancl\Tenancy\Database\Concerns\HasDatabase;
use Stancl\Tenancy\Database\Concerns\HasDomains;
 
class Tenant extends BaseTenant implements TenantWithDatabase
{
use HasDatabase, HasDomains;
}

Next, in the config/tenancy.php, we need to set that package to use our created Model.

config/tenancy.php:

use Stancl\Tenancy\Database\Models\Domain;
use Stancl\Tenancy\Database\Models\Tenant;
 
return [
'tenant_model' => Tenant::class,
'tenant_model' => \App\Models\Tenant::class,
 
// ...

Next is routing. This package suggests we need to have two types of routes: central routes and tenant routes.

bootstrap/app.php:

use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
 
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
using: function () {
$centralDomains = config('tenancy.central_domains');
 
foreach ($centralDomains as $domain) {
Route::middleware('web')
->domain($domain)
->group(base_path('routes/web.php'));
}
 
Route::middleware('web')->group(base_path('routes/tenant.php'));
},
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
//
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();

Now, what is that central domain?

It comes from the config/tenancy.php. In our case, it will be tenancy.test as a central domain, and everything else will be subdomains.

So, when someone goes to tenancy.test, registers, and then will be redirected to their subdomain, which tenant routes will cover.

config/tenancy.php:

return [
// ...
 
'central_domains' => [
'127.0.0.1',
'localhost',
'project.test',
],
 
// ...

Note: If you're using Laravel Sail, no changes are needed, and default values are good to go. Otherwise, add the domains you use.

As for the tenant routes, the new routes/tenant.php is automatically created after package installation.

In that file, you need to place the tenant routes. All routes from routes/web.php in the auth middleware group must go into tenant routes.

routes/web.php:

use App\Http\Controllers\ProfileController;
use Illuminate\Support\Facades\Route;
 
Route::get('/', function () {
return view('welcome');
});
 
Route::get('/dashboard', function () {
return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
 
Route::middleware('auth')->group(function () {
Route::resource('tasks', \App\Http\Controllers\TaskController::class);
Route::resource('projects', \App\Http\Controllers\ProjectController::class);
 
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';

routes/tenant.php:

use App\Http\Controllers\ProfileController;
 
Route::middleware([
'web',
InitializeTenancyByDomain::class,
PreventAccessFromCentralDomains::class,
])->group(function () {
Route::get('/dashboard', function () {
return view('dashboard');
})->middleware(['auth', 'verified'])->name('dashboard');
 
Route::resource('tasks', \App\Http\Controllers\TaskController::class);
Route::resource('projects', \App\Http\Controllers\ProjectController::class);
 
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');
Route::get('/', function () {
return 'This is your multi-tenant application. The id of the current tenant is ' . tenant('id');
});
});

app/Providers/TenancyServiceProvider.php:

// ...
 
protected function mapRoutes()
{
if (file_exists(base_path('routes/tenant.php'))) {
Route::namespace(static::$controllerNamespace)
->group(base_path('routes/tenant.php'));
}
 
$this->app->booted(function () { [tl! add:start]
if (file_exists(base_path('routes/tenant.php'))) {
Route::namespace(static::$controllerNamespace)
->group(base_path('routes/tenant.php'));
}
});
}
 
// ...

By default, the package is configured to use multi-database. We must disable the DatabaseTenancyBootstrapper and database creation jobs to use as a single-database.

config/tenancy.php:

return [
// ...
 
'bootstrappers' => [
Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
// Stancl\Tenancy\Bootstrappers\RedisTenancyBootstrapper::class, // Note: phpredis is needed
],
 
// ...

app/Providers/TenancyServiceProvider.php:

class TenancyServiceProvider extends ServiceProvider
{
// By default, no namespace is used to support the callable array syntax.
public static string $controllerNamespace = '';
 
public function events()
{
return [
// Tenant events
Events\CreatingTenant::class => [],
Events\TenantCreated::class => [
JobPipeline::make([
Jobs\CreateDatabase::class,
Jobs\MigrateDatabase::class,
// Jobs\SeedDatabase::class,
 
// Your own jobs to prepare the tenant.
// Provision API keys, create S3 buckets, anything you want!
 
])->send(function (Events\TenantCreated $event) {
return $event->tenant;
})->shouldBeQueued(false), // `false` by default, but you probably want to make this `true` for production.
],
// ...
];
}
 
// ...
}

New Tenant Registration

Now, let's move to creating a Tenant when the User registers. First, add a field to the register form.

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

// ...
 
<!-- Subdomain -->
<div class="mt-2">
<x-input-label for="subdomain" :value="__('Subdomain')" />
 
<div class="flex items-baseline">
<x-text-input id="subdomain" class="block mt-1 mr-2 w-full" type="text" name="subdomain" :value="old('subdomain')" required />
.{{ config('tenancy.central_domains')[0] }}
</div>
<x-input-error :messages="$errors->get('subdomain')" class="mt-2" />
</div>
 
// ...

The backend part for registration in Laravel Breeze goes to app/Http/Controllers/Auth/RegisteredUserController.php. Here, we create a Tenant, then we create a domain for that tenant and attach the tenant to the User.

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

use App\Models\Tenant;
 
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', 'unique:domains,domain'],
]);
 
$user = User::create([
'name' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
 
$tenant = Tenant::create([
'name' => $request->name . ' Team',
]);
$tenant->domains()->create([
'domain' => $request->subdomain . '.' . config('tenancy.central_domains')[0],
]);
$user->tenants()->attach($tenant->id);
 
event(new Registered($user));
 
Auth::login($user);
 
return redirect(route('dashboard', absolute: false));
return redirect('http://' . $request->subdomain . '.' . config('tenancy.central_domains')[0] . route('dashboard', absolute: false));
}
}

We need to create a many-to-many relation to attach a Tenant to a User.

database/migrations/xxxx_create_tenant_user_table:

Schema::create('tenant_user', function (Blueprint $table) {
$table->foreignId('tenant_id')->constrained();
$table->foreignId('user_id')->constrained();
});

app/Models/User.php:

use Illuminate\Database\Eloquent\Relations\BelongsToMany;
 
class User extends Authenticatable
{
// ...
 
public function tenants(): BelongsToMany
{
return $this->belongsToMany(Tenant::class);
}
}

To make it work, we also need to change the Tenant migrations to use id() instead of string for the primary key.

database/migrations/xxxx_create_tenants_table.php:

Schema::create('tenants', function (Blueprint $table) {
$table->string('id')->primary();
$table->id();
 
// your custom columns may go here
 
$table->timestamps();
$table->json('data')->nullable();
});

database/migrations/xxxx_create_domains_table.php:

Schema::create('domains', function (Blueprint $table) {
$table->increments('id');
$table->string('domain', 255)->unique();
$table->string('tenant_id');
$table->foreignId('tenant_id')->constrained()->cascadeOnUpdate()->cascadeOnDelete();
 
$table->timestamps();
$table->foreign('tenant_id')->references('id')->on('tenants')->onUpdate('cascade')->onDelete('cascade');
});
return [
'tenant_model' => \App\Models\Tenant::class,
'id_generator' => Stancl\Tenancy\UUIDGenerator::class,
'id_generator' => null,
 
'domain_model' => Domain::class,
 
// ...

The last thing: we need to set the session domain.

// ...
 
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=tenancy.test
 
// ...

To correctly render Vite assets, uncomment the ViteBundler class in the configuration.

config/tenancy.php:

return [
'features' => [
// Stancl\Tenancy\Features\UserImpersonation::class,
// Stancl\Tenancy\Features\TelescopeTags::class,
// Stancl\Tenancy\Features\UniversalRoutes::class,
// Stancl\Tenancy\Features\TenantConfig::class, // https://tenancyforlaravel.com/docs/v3/features/tenant-config
// Stancl\Tenancy\Features\CrossDomainRedirect::class, // https://tenancyforlaravel.com/docs/v3/features/cross-domain-redirect
Stancl\Tenancy\Features\ViteBundler::class,
],
];

So now, after successful registration, the User will be redirected directly to their subdomain.


So we've created our tenant, and we are inside. But for now, we haven't restricted our data of projects and tasks by tenant. So let's do that in the next lesson.