Building a Multi-Tenant SaaS Application with Laravel 12, Livewire, and Service-Based Architecture

Building a Multi-Tenant SaaS Application with Laravel 12, Livewire, and Service-Based Architecture

As SaaS (Software as a Service) continues to dominate the software industry, developers are increasingly faced with the challenge of building scalable, maintainable, and secure multi-tenant applications. Laravel 12, with its elegant syntax and modern features, provides a powerful foundation for this. Paired with Livewire for reactive UI development and a service-based architecture for cleaner code separation, developers can build robust SaaS platforms that are both user-friendly and easy to manage.

In this step-by-step guide, we’ll walk through building a multi-tenant SaaS application using Laravel 12, Livewire 3, and the Spatie Laravel Multitenancy package. You’ll learn how to set up tenant isolation, manage users, and implement a service layer that ensures your application’s business logic remains organized and reusable.

Whether you’re launching your next startup or modernizing an enterprise solution, this architecture will help you scale with confidence.

Let’s dive in and start building a future-proof SaaS platform!

1. Set Up Your 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

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:

return [
    'tenant_database_connection_name' => 'mysql',

    // Disable automatic subdomain-based tenant identification
    'tenant_finder' => null,

    // We will handle tenant resolution in our middleware
    'tenant_resolver' => null,
    
    // Other default configurations...
];

4. Create Tenant and User Models

Tenant Migration

php artisan make:migration create_tenants_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::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

php artisan make:model Tenant
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Tenant extends Model
{
    protected $fillable = ['name', 'identifier'];

    public function users()
    {
        return $this->hasMany(User::class);
    }
}

User Migration

php artisan make:migration add_tenant_id_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->foreignId('tenant_id')->nullable()->constrained()->onDelete('cascade');
            $table->boolean('is_super_admin')->default(false);
        });
    }

    public function down(): void
    {
        Schema::table('users', function (Blueprint $table) {
            $table->dropConstrainedForeignId('tenant_id');
            $table->dropColumn('is_super_admin');
        });
    }
};

User Model Update

Edit the app/Models/User.php file:

<?php

namespace App\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'password',
        'tenant_id',
        'is_super_admin',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
        'is_super_admin' => 'boolean',
    ];

    public function tenant()
    {
        return $this->belongsTo(Tenant::class);
    }
}

Run the migrations:

php artisan migrate

5. Create Service Layer

TenantService

mkdir -p app/Services

Create app/Services/TenantService.php:

<?php

namespace App\Services;

use App\Models\Tenant;
use Illuminate\Database\Eloquent\Collection;

class TenantService
{
    public function getAllTenants(): Collection
    {
        return Tenant::all();
    }
    
    public function getTenantByIdentifier(string $identifier): ?Tenant
    {
        return Tenant::where('identifier', $identifier)->first();
    }
    
    public function createTenant(array $data): Tenant
    {
        return Tenant::create($data);
    }
    
    public function updateTenant(Tenant $tenant, array $data): bool
    {
        return $tenant->update($data);
    }
    
    public function deleteTenant(Tenant $tenant): bool
    {
        return $tenant->delete();
    }
}

AuthService

Create app/Services/AuthService.php:

<?php

namespace App\Services;

use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;

class AuthService
{
    public function attemptTenantLogin(string $email, string $password, int $tenantId): bool
    {
        $user = User::where('email', $email)
                    ->where('tenant_id', $tenantId)
                    ->first();
                    
        if ($user && Hash::check($password, $user->password)) {
            Auth::login($user);
            return true;
        }
        
        return false;
    }
    
    public function createUser(array $data): User
    {
        return User::create([
            'name' => $data['name'],
            'email' => $data['email'],
            'password' => Hash::make($data['password']),
            'tenant_id' => $data['tenant_id'] ?? null,
            'is_super_admin' => $data['is_super_admin'] ?? false,
        ]);
    }
    
    public function logout(): void
    {
        Auth::logout();
    }
}

6. Middleware for Tenant Identification

php artisan make:middleware IdentifyTenant
<?php

namespace App\Http\Middleware;

use App\Services\TenantService;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class IdentifyTenant
{
    protected TenantService $tenantService;

    public function __construct(TenantService $tenantService)
    {
        $this->tenantService = $tenantService;
    }

    public function handle(Request $request, Closure $next): Response
    {
        if ($request->route('tenant_identifier')) {
            $tenant = $this->tenantService->getTenantByIdentifier($request->route('tenant_identifier'));

            if ($tenant) {
                session(['tenant_id' => $tenant->id]);
            } else {
                abort(404, 'Tenant not found');
            }
        }

        return $next($request);
    }
}
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);
    }
}

7. Register Middleware in Laravel 12

Edit bootstrap/app.php:

->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,
    ]);
})

8. Global Scope for Tenant Isolation

We need to automatically filter database queries so that each tenant only sees data that belongs to them — unless the user is a super admin.

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 base model app/Models/BaseModel.php:

<?php

namespace App\Models;

use App\Models\Scopes\TenantScope;
use Illuminate\Database\Eloquent\Model;

class BaseModel extends Model
{
    protected static function booted(): void
    {
        static::addGlobalScope(new TenantScope);
    }
}
  • BaseModel is your custom base model that all your models can extend (instead of directly using Model).
  • It automatically attaches the TenantScope whenever a model boots.

Usage

Suppose you have a model like this:

class Post extends BaseModel
{
// automatically uses the tenant scope
}

Whenever you run:

Post::all();

It automatically becomes:

SELECT * FROM posts WHERE tenant_id = [session('tenant_id')];

Unless the user is a super_admin, in which case it skips the filter.

9. Livewire Components

Login Component

php artisan make:livewire Auth/Login

Edit app/Livewire/Auth/Login.php:

<?php

namespace App\Livewire\Auth;

use App\Services\AuthService;
use Livewire\Component;

class Login extends Component
{
    public string $email = '';
    public string $password = '';
    
    protected $rules = [
        'email' => 'required|email',
        'password' => 'required',
    ];
    
    public function login(AuthService $authService)
    {
        $this->validate();
        
        $tenantId = session('tenant_id');
        
        if ($authService->attemptTenantLogin($this->email, $this->password, $tenantId)) {
            return $this->redirect('/dashboard');
        }
        
        $this->addError('email', 'The provided credentials do not match our records.');
    }
    
    public function render()
    {
        return view('livewire.auth.login');
    }
}

Create resources/views/livewire/auth/login.blade.php:

<div class="min-h-screen flex flex-col sm:justify-center items-center pt-6 sm:pt-0 bg-gray-100">
    <div class="w-full sm:max-w-md mt-6 px-6 py-4 bg-white shadow-md overflow-hidden sm:rounded-lg">
        <form wire:submit="login">
            <div>
                <label for="email" class="block font-medium text-sm text-gray-700">Email</label>
                <input id="email" type="email" wire:model="email" class="mt-1 block w-full" autofocus>
                @error('email') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
            </div>

            <div class="mt-4">
                <label for="password" class="block font-medium text-sm text-gray-700">Password</label>
                <input id="password" type="password" wire:model="password" class="mt-1 block w-full">
                @error('password') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
            </div>

            <div class="flex items-center justify-end mt-4">
                <button type="submit" class="ml-4 px-4 py-2 bg-blue-500 text-white rounded-md">
                    Log in
                </button>
            </div>
        </form>
    </div>
</div>

Admin Tenants Component

php artisan make:livewire Admin/Tenants

Edit app/Livewire/Admin/Tenants.php:

<?php

namespace App\Livewire\Admin;

use App\Services\TenantService;
use Livewire\Component;
use Livewire\WithPagination;

class Tenants extends Component
{
    use WithPagination;
    
    public string $name = '';
    public string $identifier = '';
    public ?int $editingTenantId = null;
    
    protected $rules = [
        'name' => 'required|string|max:255',
        'identifier' => 'required|string|max:255|alpha_dash|unique:tenants,identifier',
    ];
    
    public function createTenant(TenantService $tenantService)
    {
        $this->validate();
        
        $tenantService->createTenant([
            'name' => $this->name,
            'identifier' => $this->identifier,
        ]);
        
        $this->reset(['name', 'identifier']);
        session()->flash('message', 'Tenant created successfully.');
    }
    
    public function editTenant(int $tenantId, TenantService $tenantService)
    {
        $tenant = $tenantService->getAllTenants()->find($tenantId);
        $this->editingTenantId = $tenant->id;
        $this->name = $tenant->name;
        $this->identifier = $tenant->identifier;
    }
    
    public function updateTenant(TenantService $tenantService)
    {
        $this->validate([
            'name' => 'required|string|max:255',
            'identifier' => 'required|string|max:255|alpha_dash|unique:tenants,identifier,' . $this->editingTenantId,
        ]);
        
        $tenant = $tenantService->getAllTenants()->find($this->editingTenantId);
        $tenantService->updateTenant($tenant, [
            'name' => $this->name,
            'identifier' => $this->identifier,
        ]);
        
        $this->reset(['name', 'identifier', 'editingTenantId']);
        session()->flash('message', 'Tenant updated successfully.');
    }
    
    public function cancelEdit()
    {
        $this->reset(['name', 'identifier', 'editingTenantId']);
    }
    
    public function deleteTenant(int $tenantId, TenantService $tenantService)
    {
        $tenant = $tenantService->getAllTenants()->find($tenantId);
        $tenantService->deleteTenant($tenant);
        
        session()->flash('message', 'Tenant deleted successfully.');
    }
    
    public function render(TenantService $tenantService)
    {
        return view('livewire.admin.tenants', [
            'tenants' => $tenantService->getAllTenants(),
        ]);
    }
}

Create resources/views/livewire/admin/tenants.blade.php:

<div>
    @if (session()->has('message'))
        <div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
            {{ session('message') }}
        </div>
    @endif

    <div class="bg-white p-6 rounded-lg shadow-lg mb-6">
        <h3 class="text-lg font-medium text-gray-900 mb-4">
            {{ $editingTenantId ? 'Edit Tenant' : 'Create New Tenant' }}
        </h3>
        
        <form wire:submit="{{ $editingTenantId ? 'updateTenant' : 'createTenant' }}">
            <div class="mb-4">
                <label for="name" class="block text-sm font-medium text-gray-700">Name</label>
                <input type="text" id="name" wire:model="name" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
                @error('name') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
            </div>
            
            <div class="mb-4">
                <label for="identifier" class="block text-sm font-medium text-gray-700">Identifier</label>
                <input type="text" id="identifier" wire:model="identifier" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm">
                @error('identifier') <span class="text-red-500 text-sm">{{ $message }}</span> @enderror
            </div>
            
            <div class="flex justify-end">
                @if ($editingTenantId)
                    <button type="button" wire:click="cancelEdit" class="mr-2 px-4 py-2 bg-gray-500 text-white rounded-md">
                        Cancel
                    </button>
                @endif
                
                <button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-md">
                    {{ $editingTenantId ? 'Update' : 'Create' }}
                </button>
            </div>
        </form>
    </div>

    <div class="bg-white overflow-hidden shadow-sm rounded-lg">
        <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">ID</th>
                    <th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
                    <th class="px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase">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="/{{ $tenant->identifier }}/dashboard" class="text-indigo-600 hover:text-indigo-900 mr-3">View</a>
                            <button wire:click="editTenant({{ $tenant->id }})" class="text-yellow-600 hover:text-yellow-900 mr-3">Edit</button>
                            <button wire:click="deleteTenant({{ $tenant->id }})" class="text-red-600 hover:text-red-900" onclick="return confirm('Are you sure?')">Delete</button>
                        </td>
                    </tr>
                @endforeach
            </tbody>
        </table>
    </div>
</div>

Dashboard Component

php artisan make:livewire Dashboard

Edit app/Livewire/Dashboard.php:

<?php

namespace App\Livewire;

use App\Models\User;
use App\Services\TenantService;
use Livewire\Component;

class Dashboard extends Component
{
    public function render(TenantService $tenantService)
    {
        $tenantId = session('tenant_id');
        $tenant = $tenantService->getAllTenants()->find($tenantId);
        
        // Get tenant users if the user is authenticated
        $users = [];
        if (auth()->check()) {
            $users = User::where('tenant_id', $tenantId)->get();
        }
        
        return view('livewire.dashboard', [
            'tenant' => $tenant,
            'tenantId' => $tenantId,
            'users' => $users,
        ]);
    }
}

Create resources/views/livewire/dashboard.blade.php:

<div>
    <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
        <h1 class="text-3xl font-bold text-gray-900">Dashboard</h1>
        <p class="mt-2 text-gray-600">You're logged in to tenant: {{ $tenant->name }} (ID: {{ $tenantId }})</p>
    </div>
    
    <div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
        <div class="bg-white shadow overflow-hidden sm:rounded-lg">
            <div class="px-4 py-5 sm:px-6">
                <h3 class="text-lg leading-6 font-medium text-gray-900">Tenant Users</h3>
            </div>
            <div class="border-t border-gray-200">
                <dl>
                    @forelse ($users as $user)
                        <div class="bg-gray-50 px-4 py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
                            <dt class="text-sm font-medium text-gray-500">{{ $user->name }}</dt>
                            <dd class="mt-1 text-sm text-gray-900 sm:col-span-2">{{ $user->email }}</dd>
                        </div>
                    @empty
                        <div class="bg-gray-50 px-4 py-5 sm:px-6">
                            <p class="text-sm text-gray-500">No users found.</p>
                        </div>
                    @endforelse
                </dl>
            </div>
        </div>
    </div>
</div>

10. Create Layout

Create resources/views/components/layouts/app.blade.php:

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="csrf-token" content="{{ csrf_token() }}">

    <title>{{ config('app.name', 'Laravel') }}</title>

    <!-- Scripts and Styles -->
    @vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body class="font-sans antialiased">
    <div class="min-h-screen bg-gray-100">
        <nav class="bg-white border-b border-gray-100">
            <div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
                <div class="flex justify-between h-16">
                    <div class="flex">
                        <div class="flex-shrink-0 flex items-center">
                            <a href="/" class="font-bold text-xl">{{ config('app.name', 'Laravel') }}</a>
                        </div>
                    </div>
                    
                    <div class="flex items-center">
                        @auth
                            <div class="ml-3 relative">
                                <div>
                                    <span class="text-gray-800">{{ auth()->user()->name }}</span>
                                    <form method="POST" action="{{ route('logout') }}" class="inline-block ml-4">
                                        @csrf
                                        <button type="submit" class="text-sm text-gray-500 hover:text-gray-700">
                                            Log Out
                                        </button>
                                    </form>
                                </div>
                            </div>
                        @else
                            <a href="{{ route('login') }}" class="text-sm text-gray-700 dark:text-gray-500 underline">Log in</a>
                        @endauth
                    </div>
                </div>
            </div>
        </nav>

        <main>
            {{ $slot }}
        </main>
    </div>
</body>
</html>

11. Routes (in Laravel 12 Style)

Edit routes/web.php:

<?php

use App\Livewire\Auth\Login;
use App\Livewire\Admin\Tenants as AdminTenants;
use App\Livewire\Dashboard;
use Illuminate\Support\Facades\Route;

// Auth routes
Route::get('/login', Login::class)->name('login');

// Define a route for logout
Route::post('/logout', function () {
    auth()->logout();
    session()->invalidate();
    session()->regenerateToken();
    return redirect('/');
})->name('logout');

// Tenant routes with middleware
Route::middleware(['identify.tenant'])->group(function () {
    Route::get('/{tenant_identifier}/dashboard', Dashboard::class)->name('tenant.dashboard');
});

// Admin routes
Route::middleware(['auth', 'super.admin'])->prefix('admin')->group(function () {
    Route::get('/tenants', AdminTenants::class)->name('admin.tenants');
});

// Redirect root to admin or login
Route::get('/', function () {
    if (auth()->check() && auth()->user()->is_super_admin) {
        return redirect()->route('admin.tenants');
    }
    return redirect()->route('login');
});

12. Seed Data

php artisan make:seeder TenantUserSeeder

Edit database/seeders/TenantUserSeeder.php:

<?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'),
        ]);
        
        // Create another tenant
        $tenant2 = Tenant::create([
            'name' => 'Wayne Enterprises',
            'identifier' => 'wayne',
        ]);

        // Create tenant users
        $tenant2->users()->create([
            'name' => 'Bruce Wayne',
            'email' => 'bruce@wayne.com',
            'password' => Hash::make('password'),
        ]);
    }
}

Update database/seeders/DatabaseSeeder.php:

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;

class DatabaseSeeder extends Seeder
{
    public function run(): void
    {
        $this->call([
            TenantUserSeeder::class,
        ]);
    }
}

Run the seeder:

php artisan db:seed

13. Configure Vite for CSS

Edit vite.config.js to include Tailwind CSS:

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
});

Install Tailwind CSS:

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Create tailwind.config.js:

/** @type {import('tailwindcss').Config} */
export default {
  content: [
    './resources/**/*.blade.php',
    './resources/**/*.js',
    './app/Livewire/**/*.php',
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Edit resources/css/app.css:

@tailwind base;
@tailwind components;
@tailwind utilities;

14. Run the Application

Install NPM dependencies and build assets:

npm install
npm run build

Start the application:

php artisan serve

Testing the Application

  1. Visit the login page at http://localhost:8000/login
  2. Log in as the super admin: superadmin@example.com / password
  3. Create, edit, and delete tenants from the admin page
  4. Visit a tenant’s dashboard at http://localhost:8000/{tenant_identifier}/dashboard (e.g., http://localhost:8000/acme/dashboard)
  5. Log in as a tenant user (e.g., john@acme.com / password) while on the tenant’s URL

This implementation uses Livewire components and service classes instead of traditional controllers, following Laravel 12’s modern architectural patterns and providing a more interactive user experience.

Similar Posts

Leave a Reply

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