Multi-tenant SaaS Application Setup Guide for Laravel 12

This guide provides a step-by-step approach to setting up a multi-tenant SaaS application without subdomains using Laravel 12 and the Spatie Laravel Multitenancy package. The setup uses a shared database approach with tenant identification based on unique identifiers.
1. Set Up Your Laravel 12 Project
Start by creating a new Laravel 12 project:
composer create-project laravel/laravel:^12.0 saas-app
cd saas-app
Install Livewire for interactive UI components:
composer require livewire/livewire:^3.0
2. Install Spatie Laravel Multitenancy
Install the Spatie Laravel Multitenancy package:
composer require spatie/laravel-multitenancy:^3.0
Publish the configuration file:
php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="multitenancy-config"
3. Configure Multi-Tenancy
Update the config/multitenancy.php
file to configure shared database multi-tenancy:
'tenant_database_connection_name' => 'mysql',
// Disable automatic subdomain-based tenant identification
'tenant_finder' => null,
// Register your custom tenant resolver in the service provider
4. Create Tenant and User Models
Tenant Migration
Create a migration for the tenants table:
php artisan make:migration create_tenants_table
Add the following schema:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('identifier')->unique();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tenants');
}
};
Tenant Model
Create the Tenant model:
php artisan make:model Tenant
Define the relationship between tenants and users:
<?php
namespace App\Models;
class Tenant extends Model
{
protected $fillable = ['name', 'identifier'];
public function users(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(User::class);
}
}
User Migration
Modify the users table to include a tenant_id column:
php artisan make:migration add_tenant_id_to_users_table
Add the following schema:
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->foreignId('tenant_id')->nullable()->constrained()->onDelete('cascade');
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropConstrainedForeignId('tenant_id');
});
}
};
User Model
Update the User model to include the relationship with the tenant:
<?php
namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
// Other properties and methods...
public function tenant(): \Illuminate\Database\Eloquent\Relations\BelongsTo
{
return $this->belongsTo(Tenant::class);
}
}
Run the migrations:
php artisan migrate
5. Middleware for Tenant Identification
Create a middleware for tenant identification:
php artisan make:middleware IdentifyTenant
Implement the logic to identify tenants:
<?php
namespace App\Http\Middleware;
use App\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class IdentifyTenant
{
public function handle(Request $request, Closure $next): Response
{
if ($request->route('tenant_identifier')) {
$tenant = Tenant::where('identifier', $request->route('tenant_identifier'))->first();
if ($tenant) {
session(['tenant_id' => $tenant->id]);
}
}
return $next($request);
}
}
6. Register Middleware in Laravel 12
In Laravel 12, the Kernel class has been removed. Instead, middleware is registered directly in your application’s bootstrap/app.php file.
Edit bootstrap/app.php
to register your custom middleware:
->withMiddleware(function (Middleware $middleware) {
// Add your middleware aliases here
$middleware->alias([
'identify.tenant' => \App\Http\Middleware\IdentifyTenant::class,
'super.admin' => \App\Http\Middleware\SuperAdmin::class,
]);
})
7. Global Scope for Tenant Isolation
Create a global scope to ensure that all tenant-specific queries are scoped to the current tenant:
mkdir -p app/Models/Scopes
Create app/Models/Scopes/TenantScope.php
:
<?php
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TenantScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if (auth()->check() && auth()->user()->is_super_admin) {
return; // Skip tenant scoping for super admins
}
if (session('tenant_id')) {
$builder->where('tenant_id', session('tenant_id'));
}
}
}
Create a BaseModel that all tenant-aware models will extend:
<?php
namespace App\Models;
class BaseModel extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new \App\Models\Scopes\TenantScope);
}
}
8. Authentication and Authorization
Modify Login Logic
Create a custom authentication controller:
php artisan make:controller Auth/LoginController
Update with tenant-aware login logic:
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
class LoginController extends Controller
{
public function login(Request $request)
{
$credentials = $request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
$user = User::where('email', $credentials['email'])
->where('tenant_id', session('tenant_id'))
->first();
if ($user && Hash::check($credentials['password'], $user->password)) {
Auth::login($user);
return redirect()->intended('/dashboard');
}
return back()->withErrors([
'email' => 'The provided credentials do not match our records.',
]);
}
}
Super Admin Logic
Add a is_super_admin column to the users table:
php artisan make:migration add_super_admin_to_users_table
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_super_admin')->default(false);
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_super_admin');
});
}
};
Create the SuperAdmin middleware:
php artisan make:middleware SuperAdmin
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class SuperAdmin
{
public function handle(Request $request, Closure $next): Response
{
if (!auth()->check() || !auth()->user()->is_super_admin) {
abort(403, 'Unauthorized action.');
}
return $next($request);
}
}
9. Routes and Controllers
Define routes for tenant-specific and admin functionality in routes/web.php
:
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\AdminController;
// Tenant routes
Route::middleware(['identify.tenant'])->group(function () {
Route::get('/{tenant_identifier}/dashboard', [DashboardController::class, 'index'])
->name('tenant.dashboard');
// Add more tenant-specific routes here
});
// Admin routes
Route::middleware(['auth', 'super.admin'])->prefix('admin')->group(function () {
Route::get('/tenants', [AdminController::class, 'listTenants'])
->name('admin.tenants');
// Add more admin routes here
});
10. Create Controllers
Create Dashboard and Admin controllers:
php artisan make:controller DashboardController
php artisan make:controller AdminController
Implement the DashboardController:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class DashboardController extends Controller
{
public function index(Request $request)
{
// Get the current tenant ID from session
$tenantId = session('tenant_id');
// Fetch tenant-specific data here
return view('dashboard', [
'tenantId' => $tenantId,
// Pass other data to the view
]);
}
}
Implement the AdminController:
<?php
namespace App\Http\Controllers;
use App\Models\Tenant;
use Illuminate\Http\Request;
class AdminController extends Controller
{
public function listTenants()
{
$tenants = Tenant::all();
return view('admin.tenants', [
'tenants' => $tenants,
]);
}
}
11. Seed Data
Create a seeder for testing:
php artisan make:seeder TenantUserSeeder
Implement the seeder:
<?php
namespace Database\Seeders;
use App\Models\Tenant;
use App\Models\User;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash;
class TenantUserSeeder extends Seeder
{
public function run(): void
{
// Create a super admin
User::create([
'name' => 'Super Admin',
'email' => 'superadmin@example.com',
'password' => Hash::make('password'),
'is_super_admin' => true,
]);
// Create a tenant
$tenant = Tenant::create([
'name' => 'Acme Corp',
'identifier' => 'acme',
]);
// Create a tenant user
$tenant->users()->create([
'name' => 'John Doe',
'email' => 'john@acme.com',
'password' => Hash::make('password'),
]);
}
}
Run the seeder:
php artisan db:seed --class=TenantUserSeeder
12. Creating Views
Create the necessary views:
mkdir -p resources/views/admin
Create resources/views/dashboard.blade.php
:
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Dashboard') }}
</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 text-gray-900">
{{ __("You're logged in to tenant with ID: ") }} {{ $tenantId }}
</div>
</div>
</div>
</div>
</x-app-layout>
Create resources/views/admin/tenants.blade.php
:
<x-app-layout>
<x-slot name="header">
<h2 class="font-semibold text-xl text-gray-800 leading-tight">
{{ __('Tenants') }}
</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 text-gray-900">
<table class="min-w-full divide-y divide-gray-200">
<thead>
<tr>
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Identifier</th>
<th class="px-6 py-3 bg-gray-50"></th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach ($tenants as $tenant)
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $tenant->id }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $tenant->name }}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $tenant->identifier }}</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="{{ route('tenant.dashboard', ['tenant_identifier' => $tenant->identifier]) }}" class="text-indigo-600 hover:text-indigo-900">View</a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</x-app-layout>
13. Testing Your Setup
Test the following scenarios:
- Super admin access
- Navigate to
/admin/tenants
after logging in as a super admin
- Navigate to
- Tenant-specific access
- Navigate to
/{tenant_identifier}/dashboard
(e.g.,/acme/dashboard
) - Verify that tenant-specific data is properly isolated
- Navigate to
- Authentication within tenant context
- Attempt to log in as a tenant user while visiting the tenant’s URL
- Verify that the user can only access their tenant’s data
This completes the setup for a multi-tenant SaaS application using Laravel 12 without subdomains.