Multi-tenant SaaS Application Setup Guide for Laravel 12

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:

  1. Super admin access
    • Navigate to /admin/tenants after logging in as a super admin
  2. Tenant-specific access
    • Navigate to /{tenant_identifier}/dashboard (e.g., /acme/dashboard)
    • Verify that tenant-specific data is properly isolated
  3. 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.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *