$strings = [ 'author_byline' => 'Par F. Rahmouni Oussama', 'author_title' => 'Formateur en Développement Informatique & Data Science, ISMO', 'last_update' => 'Dernière mise à jour', 'toc_title' => 'Parcours d\'apprentissage', 'conclusion_link' => 'Conclusion & Perspectives', 'copyright' => 'Tous droits réservés', 'language_fr' => 'Français', 'language_en' => 'English', ]; Laravel Saas - Création d'une Application Laravel 12 Multi-Tenant, dynamique thèmes et développement modulaire de A à Z
Photo de F. Rahmouni Oussama

Laravel Saas - Création d'une Application Laravel 12 Multi-Tenant, dynamique thèmes et développement modulaire de A à Z

: Novembre 2025

Partie 1 : Initialisation du Projet et Prérequis

Chapitre 1 : Installation de Laravel 12 et du Layout de base

Nous commençons par la base : la création d'un projet Laravel 12 frais, la configuration de la base de données et la mise en place d'une structure de layout simple avec un composant Blade pour être prêts à construire notre interface.

1.1. Création du projet Laravel

composer create-project laravel/laravel multitenants

1.2. Configuration de l'environnement (.env)

Modifiez votre fichier .env pour connecter le projet à votre base de données MySQL centrale.

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_multitenant_central
DB_USERNAME=root
DB_PASSWORD=

1.3. Lancement de la première migration

Exécutez la migration initiale pour créer les tables de base de Laravel (utilisateurs, etc.) dans la base de données centrale.

php artisan migrate

1.4. Création du composant de Layout

Nous créons un composant AppLayout qui servira de structure principale pour les pages de notre application.

php artisan make:component AppLayout

Cela génère App/View/Components/AppLayout.php et resources/views/components/app-layout.blade.php. Vous pouvez ensuite structurer votre layout en y incluant des partiels comme un header et une sidebar (main-sidebar.blade.php).

Chapitre 2 : Intégration de Redis pour le Cache & Sessions

Pour améliorer les performances, nous configurons Redis comme pilote pour le cache, les sessions et les files d'attente.

2.1. Installation du client Predis via Composer

composer require predis/predis

2.2. Mise à jour du fichier .env

Modifiez ces variables pour que Laravel utilise Redis. Attention, si la ligne CACHE_STORE=database est présente, elle doit être supprimée pour éviter les conflits.

CACHE_DRIVER=redis
SESSION_DRIVER=redis
QUEUE_CONNECTION=redis

REDIS_CLIENT=predis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_DB=0
REDIS_CACHE_DB=1

2.3. Vérification des fichiers de configuration

Assurez-vous que les fichiers config/database.php et config/cache.php sont bien configurés pour utiliser ces variables d'environnement Redis.

Voir la configuration type

Dans config/database.php :

'redis' => [
    'client' => env('REDIS_CLIENT', 'phpredis'),
    'default' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD', null),
        'port' => env('REDIS_PORT', 6379),
        'database' => env('REDIS_DB', 0),
    ],
    'cache' => [
        'host' => env('REDIS_HOST', '127.0.0.1'),
        'password' => env('REDIS_PASSWORD', null),
        'port' => env('REDIS_PORT', 6379),
        'database' => env('REDIS_CACHE_DB', 1),
    ],
],

Dans config/cache.php :

'stores' => [
    // ...
    'redis' => [
        'driver' => 'redis',
        'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
        'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
    ],
    // ...
],

2.4. Tester la connexion Redis

Utilisez l'interface en ligne de commande de Redis pour vous assurer que le serveur fonctionne et répond.

redis-cli
127.0.0.1:6379> ping
PONG
127.0.0.1:6379> exit

Partie 2 : Architecture Multi-Tenant

Chapitre 3 : Mise en place de Stancl/Tenancy

Nous installons le package stancl/tenancy, qui va nous fournir tous les outils pour gérer des bases de données séparées pour chaque client (tenant).

3.1. Installation du package

composer require stancl/tenancy
php artisan tenancy:install

3.2. Création du modèle Tenant

Créez le fichier app/Models/Tenant.php. Ce modèle Eloquent représentera nos clients dans la base de données centrale.

<?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;

    protected $fillable = ['id', 'data'];
    protected $casts = [
        'data' => 'array',
    ];
}

3.3. Configuration et migration centrale

Ouvrez config/tenancy.php et assurez-vous que la clé tenant_model pointe vers votre nouveau modèle. Ensuite, lancez la migration pour créer la table des tenants.

// In config/tenancy.php
'tenant_model' => \App\Models\Tenant::class,
php artisan migrate

Chapitre 4 : Migrations, Seeders et Routage Tenant

Nous allons maintenant structurer notre application pour gérer les migrations et les routes spécifiques à chaque tenant.

4.1. Migrations des Tenants

Les migrations pour les tables des tenants (ex: produits, factures...) doivent être placées dans database/migrations/tenant. Pour en créer une :

php artisan make:migration create_eleves_table --path=database/migrations/tenant

Pour exécuter ces migrations sur tous les tenants existants ou sur un tenant spécifique :

# Pour tous les tenants
php artisan tenants:migrate

# Pour un seul tenant
php artisan tenants:migrate --tenant=ecole1

# Pour réinitialiser et ré-exécuter les migrations pour tous les tenants
php artisan tenants:migrate-fresh

4.2. Seeders des Tenants

Pour que les commandes de seeding ciblent la bonne classe, configurez config/tenancy.php :

// Dans config/tenancy.php
'seeder_parameters' => [
    '--class' => 'TenantDatabaseSeeder', // root seeder class
],

Ensuite, lancez le seeding :

php artisan tenants:seed

4.3. Créer et configurer le TenancyServiceProvider

Ce Service Provider, fourni par le package, est crucial. Assurez-vous qu'il est bien créé (app/Providers/TenancyServiceProvider.php) et enregistré dans bootstrap/providers.php.

<?php

// Dans bootstrap/providers.php
return [
    App\Providers\AppServiceProvider::class,
    App\Providers\TenancyServiceProvider::class, // Ajoutez cette ligne
];

4.4. Séparation des routes

Le fichier routes/tenant.php contiendra les routes accessibles via un domaine de tenant. Le fichier routes/web.php est pour l'application centrale.

Exemple pour routes/tenant.php:

<?php
// routes/tenant.php
declare(strict_types=1);

use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains;

Route::middleware([
    'web',
    InitializeTenancyByDomain::class,
    PreventAccessFromCentralDomains::class,
])->group(function () {
    Route::middleware('guest')->group(function () {
        Route::get('/login', [\App\Http\Controllers\AuthController::class, 'loginView'])->name('login');
        Route::post('/login', [\App\Http\Controllers\AuthController::class, 'login'])->name('login');
    });

    Route::middleware('auth')->group(function () {
        Route::post('/logout', [\App\Http\Controllers\AuthController::class, 'logout'])->name('logout');
        Route::get('/', [\App\Http\Controllers\PagesController::class, 'index'])->name('index');
    });
});

Exemple pour routes/web.php:

<?php
// routes/web.php
use Illuminate\Support\Facades\Route;

Route::middleware('guest')->group(function () {
    Route::get('/loginadmin', [\App\Http\Controllers\AuthController::class, 'loginadmin'])->name('loginadmin');
    Route::post('/loginadmin', [\App\Http\Controllers\AuthController::class, 'loginadminpost'])->name('loginadminpost');
});

Route::middleware('auth')->group(function () {
    Route::post('/logoutadmin', [\App\Http\Controllers\AuthController::class, 'logoutadmin'])->name('logoutadmin');
    Route::get('/indexadmin', [\App\Http\Controllers\PagesController::class, 'tenants'])->name('indexadmin');
    
    // Ajout des routes pour le CRUD des tenants
    Route::resource('tenants', \App\Http\Controllers\TenantController::class)->except(['show']);
});

4.5. Créer un premier Tenant pour tester

Utilisons Tinker pour créer manuellement notre premier tenant.

php artisan tinker

# Dans Tinker
use App\Models\Tenant;
$tenant = Tenant::create(['id' => 'ecole1']);
$tenant->domains()->create(['domain' => 'ecole1.manar.com']);
exit;

N'oubliez pas d'ajouter 127.0.0.1 ecole1.manar.com à votre fichier hosts pour pouvoir y accéder localement via `http://ecole1.manar.com:8000`.

Partie 3 : Gestion des Tenants (Application Centrale)

Chapitre 5 : Création du CRUD pour les Tenants

Nous construisons l'interface dans notre application centrale pour lister, créer, modifier et supprimer des tenants. Cela inclut le contrôleur et les vues Blade.

5.1. Le contrôleur : TenantController.php

Ce contrôleur gère la logique pour le CRUD des tenants, incluant la recherche, le tri, la pagination et l'export Excel.

<?php

namespace App\Http\Controllers;

use App\Models\Tenant;
use Illuminate\Http\Request;
use App\Exports\TenantsExport;
use Maatwebsite\Excel\Facades\Excel;
use Illuminate\Validation\Rule;

class TenantController extends Controller
{
    public function index(Request $request)
    {
        $filters = [
            'search'   => $request->input('search', ''),
            'sort_by'  => $request->input('sort_by', 'created_at'),
            'sort_dir' => $request->input('sort_dir', 'desc'),
            'per_page' => $request->input('per_page', 10),
        ];

        $query = Tenant::with('domains');

        if (!empty($filters['search'])) {
            $query->where(function ($q) use ($filters) {
                $q->where('id', 'like', '%' . $filters['search'] . '%')
                  ->orWhere('data->name', 'like', '%' . $filters['search'] . '%')
                  ->orWhereHas('domains', function ($domainQuery) use ($filters) {
                      $domainQuery->where('domain', 'like', '%' . $filters['search'] . '%');
                  });
            });
        }
        
        $query->orderBy($filters['sort_by'], $filters['sort_dir']);
        
        $tenants = $query->paginate($filters['per_page'])->withQueryString();

        return view('tenants.index', compact('tenants', 'filters'));
    }

    public function create()
    {
        return view('tenants.create');
    }

    public function store(Request $request)
    {
        $validated = $request->validate([
            'id' => 'required|string|unique:tenants|alpha_dash|min:3',
            'domain' => ['required', 'string', 'unique:domains,domain', 'regex:/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'],
            'data' => 'required|array',
            'data.name' => 'required|string|max:255',
            'data.plan' => 'nullable|string',
        ]);

        $tenant = Tenant::create($validated);
        $tenant->createDomain(['domain' => $validated['domain']]);

        return redirect()->route('tenants.index')->with('success', 'Tenant créé avec succès.');
    }

    public function edit(Tenant $tenant)
    {
        $tenant->load('domains');
        return view('tenants.edit', compact('tenant'));
    }

    public function update(Request $request, Tenant $tenant)
    {
        $validated = $request->validate([
            'domain' => [
                'required', 'string',
                Rule::unique('domains')->ignore($tenant->domains->first()?->id),
                'regex:/^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'
            ],
            'data' => 'required|array',
            'data.name' => 'required|string|max:255',
            'data.plan' => 'nullable|string',
        ]);

        $tenant->update(['data' => $validated['data']]);
        
        if ($tenant->domains->first()) {
            $tenant->domains->first()->update(['domain' => $validated['domain']]);
        } else {
            $tenant->createDomain(['domain' => $validated['domain']]);
        }
        
        return redirect()->route('tenants.index')->with('success', 'Tenant mis à jour avec succès.');
    }
    
    public function destroy(Tenant $tenant)
    {
        $tenant->delete();
        return redirect()->route('tenants.index')->with('success', 'Tenant supprimé avec succès.');
    }

    public function export()
    {
        return Excel::download(new TenantsExport, 'tenants.xlsx');
    }
}

5.2. Les Vues Blade

Nous avons besoin de plusieurs vues pour le CRUD : une pour la liste (index.blade.php), une pour la création (create.blade.php), une pour l'édition (edit.blade.php), et un formulaire partiel (_form.blade.php).

resources/views/tenants/index.blade.php:

<x-app-layout title="Gestion des Tenants">
    <!-- Code complet de la vue index.blade.php -->
</x-app-layout>
@push('scripts')
<script>
document.addEventListener('alpine:init', () => {
    Alpine.data('columnsManager', () => ({
        // ... Code Alpine.js complet ...
    }));
});
</script>
@endpush

resources/views/tenants/create.blade.php:

<x-app-layout title="Créer un Tenant">
    <div class="py-12 px-4 sm:px-6 lg:px-8">
        <div class="max-w-4xl mx-auto">
            <div class="bg-white dark:bg-gray-800 shadow-sm sm:rounded-lg">
                <div class="p-6 md:p-8">
                    <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-200">
                        Créer un nouveau tenant
                    </h2>

                    <form action="{{ route('tenants.store') }}" method="POST" class="mt-8">
                        @csrf
                        @include('tenants._form')

                        <div class="flex items-center justify-end space-x-4 mt-8">
                            <a href="{{ route('tenants.index') }}" class="btn bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-white">Annuler</a>
                            <button type="submit" class="btn bg-primary text-white">Créer le Tenant</button>
                        </div>
                    </form>
                </div>
            </div>
        </div>
    </div>
</x-app-layout>

resources/views/tenants/_form.blade.php:

@if ($errors->any())
    <div class="mb-4 bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
        <ul>
            @foreach ($errors->all() as $error)
                <li>{{ $error }}</li>
            @endforeach
        </ul>
    </div>
@endif

<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
    <!-- ID du Tenant (uniquement à la création) -->
    @unless(isset($tenant))
    <div class="md:col-span-2">
        <label for="id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">ID du Tenant</label>
        <input type="text" name="id" id="id" value="{{ old('id') }}"
               class="mt-1  p-2  block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary focus:border-primary dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200"
               placeholder="ex: acme, societex" required>
        <p class="mt-2 p-2  text-xs text-gray-500">Doit être unique, sans espaces ni caractères spéciaux (sauf tirets).</p>
    </div>
    @endunless

    <!-- Nom de l'entreprise (dans data) -->
    <div>
        <label for="data_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Nom de l'entreprise</label>
        <input type="text" name="data[name]" id="data_name" value="{{ old('data.name', isset($tenant) ? $tenant->data['name'] ?? '' : '') }}"
               class="mt-1 p-2 block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary focus:border-primary dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200" required>
    </div>

    <!-- Domaine principal -->
    <div>
        <label for="domain" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Domaine principal</label>
        <input type="text" name="domain" id="domain" value="{{ old('domain', isset($tenant) ? $tenant->domains->first()?->domain ?? '' : '') }}"
               class="mt-1 p-2  block w-full border-gray-300 rounded-md shadow-sm focus:ring-primary focus:border-primary dark:bg-gray-700 dark:border-gray-600 dark:text-gray-200"
               placeholder="ex: client.mondomaine.com" required>
    </div>
</div>

Chapitre 6 : Automatisation de la création des BDD

Lorsque nous créons un tenant via notre interface, sa base de données, ses tables et ses données initiales doivent être créées automatiquement en arrière-plan. Nous utilisons les événements et les jobs de Laravel pour cela.

6.1. Le Listener `SetupTenantDatabaseListener`

Ce listener écoute l'événement TenantCreated et délègue le travail à un job en file d'attente.

<?php // app/Listeners/SetupTenantDatabaseListener.php
namespace App\Listeners;

use Stancl\Tenancy\Events\TenantCreated;
use App\Jobs\SetupTenantDatabase;

class SetupTenantDatabaseListener
{
    public function handle(TenantCreated $event)
    {
        $tenantModel = $event->tenant;
        if ($tenantModel->database_initialized) {
            return; // déjà traité
        }
        SetupTenantDatabase::dispatch($tenantModel);
    }
}

6.2. Le Job `SetupTenantDatabase`

Ce job est responsable de l'exécution des migrations et des seeders pour le nouveau tenant.

<?php // app/Jobs/SetupTenantDatabase.php
namespace App\Jobs;

use Stancl\Tenancy\Jobs\MigrateDatabase;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Database\Seeders\tenant\TenantDatabaseSeeder;

class SetupTenantDatabase implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    protected $tenant;

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

    public function handle()
    {
        // 1. Migration
        MigrateDatabase::dispatchSync($this->tenant);

        // 2. Initialiser le contexte du tenant
        tenancy()->initialize($this->tenant);

        // 3. Lancer le Seeder
        (new TenantDatabaseSeeder())->run();

        // 4. Terminer le contexte
        tenancy()->end();
    }
}

6.3. Enregistrement de l'événement et configuration

Nous enregistrons le listener dans AppServiceProvider et nous configurons les domaines centraux pour la production.

<?php // app/Providers/AppServiceProvider.php
namespace App\Providers;

use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
use Stancl\Tenancy\Events\TenantCreated;
use App\Listeners\SetupTenantDatabaseListener;

class AppServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        Event::listen(
            TenantCreated::class,
            SetupTenantDatabaseListener::class,
        );
    }
}

N'oubliez pas d'ajouter votre domaine de production à la liste des domaines centraux dans config/tenancy.php pour éviter qu'il soit traité comme un tenant.

// config/tenancy.php
'central_domains' => [ 
    '127.0.0.1',
    'localhost',
    'central.manar.com', // Très important pour la production
],

Partie 4 : Modularisation de l'Application

Chapitre 7 : Intégration de Laravel-Modules (Nwidart)

Pour les grosses applications, il est préférable de diviser le code en modules (ex: Facturation, Scolarité). Nous utilisons le package nwidart/laravel-modules pour y parvenir.

7.1. Installation et publication

composer require nwidart/laravel-modules
php artisan vendor:publish --provider="Nwidart\Modules\LaravelModulesServiceProvider"

7.2. Configuration de composer.json

Ajoutez le plugin `merge-plugin` pour que Composer découvre automatiquement les dépendances de chaque module.

"extra": {
    "laravel": {
        "dont-discover": []
    },
    "merge-plugin": {
        "include": [
            "Modules/*/composer.json"
        ]
    }
},

7.3. Création d'un premier module

Cette commande va créer un dossier Modules/School avec toute une arborescence (Contrôleurs, Modèles, Vues, etc.).

php artisan module:make School

7.4. Recharger l'autoload de Composer

Après avoir créé un module, il est crucial de mettre à jour le chargement automatique des classes.

composer dump-autoload
php artisan optimize:clear

Chapitre 8 : Migrations et Routes dans les Modules

Travailler avec des modules change la façon dont nous gérons les migrations et les routes, surtout dans un contexte multi-tenant.

8.1. Migrations des tenants dans un module

Pour que tenancy découvre automatiquement les migrations des tenants de nos modules, nous devons mettre à jour config/tenancy.php.

// dans config/tenancy.php
'migration_parameters' => [
    '--force' => true,
    '--path' => [
        'database/migrations/tenant',
        'Modules/*/database/migrations/tenant' // Ajout de cette ligne
    ],
    '--realpath' => true,
],

Ensuite, pour créer une migration de tenant pour un module, spécifiez le chemin :

php artisan make:migration create_test_table --path="Modules/School/database/migrations/tenant"

8.2. Seeders des tenants dans un module

Votre seeder principal de tenant (ex: `TenantDatabaseSeeder.php`) peut appeler les seeders de chaque module.

<?php // database/seeders/TenantDatabaseSeeder.php
namespace Database\Seeders;

use App\Models\User;
use Illuminate\Database\Seeder;

class TenantDatabaseSeeder extends Seeder
{
    public function run(): void
    {
        User::factory()->create([
            'name' => 'Admin Tenant',
            'email' => 'admin@tenant.com',
            'password' => bcrypt('password'),
        ]);

        // Appeler les seeders des modules
        $this->call(\Modules\School\Database\Seeders\SchoolDatabaseSeeder::class);
        // $this->call(\Modules\OtherModule\Database\Seeders\OtherModuleSeeder::class);
    }
}

8.3. Routes des modules et contexte du tenant

Pour que les routes d'un module soient conscientes du tenant actuel, créez un alias pour le middleware de `tenancy` dans bootstrap/app.php.

<?php // dans bootstrap/app.php
// ...
->withMiddleware(function (Middleware $middleware) {
     $middleware->alias([
        'tenant' => \Stancl\Tenancy\Middleware\InitializeTenancyByDomain::class,
    ]);
    // ...
})
// ...

Ensuite, dans le fichier de routes de votre module (ex: Modules/School/routes/web.php), protégez vos routes ainsi :

<?php
use Illuminate\Support\Facades\Route;
use Modules\School\Http\Controllers\SchoolController;

Route::middleware(['web', 'tenant', 'auth'])->prefix('admin')->group(function () {
    Route::get('/schools', [SchoolController::class, 'index'])->name('schools.index');
});

Part 5: Dynamic Themes per Tenant

Chapter 9: Configuring Vite.js and Theme Helpers

To offer deep customization, we will allow each tenant to have their own theme. The first step is to configure our asset bundler, Vite.js.

9.1. Update `vite.config.js`

We need to list the entry points (JS/CSS files) for each theme in Vite's configuration file.

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

export default defineConfig({
    plugins: [
        laravel({
            input: [
                // Central theme
                'resources/css/app.css',
                'resources/js/app.js',
                
                // Theme minimalist
                'resources/views/themes/minimalist/assets/css/app.css',
                'resources/views/themes/minimalist/assets/js/app.js',

                // Theme modern
                'resources/views/themes/modern/assets/css/app.css',
                'resources/views/themes/modern/assets/js/app.js',
            ],
            refresh: true,
        }),
        tailwindcss(),
    ],
});

9.2. Create a `theme_vite()` helper

To load the correct assets in our views, we create a custom helper function. Create the app/helpers.php file and register it in composer.json to be autoloaded.

<?php

use App\Services\ThemeManager;
use Illuminate\Foundation\Vite;
use Illuminate\Support\HtmlString;

if (! function_exists('theme_vite')) {
    function theme_vite(string|array $entrypoints): HtmlString
    {
        $themeSlug = app(ThemeManager::class)->getCurrentThemeSlug();
        if (!$themeSlug) {
            return new HtmlString('');
        }

        $projectRelativeEntrypoints = collect($entrypoints)->map(function ($entrypoint) use ($themeSlug) {
            return 'resources/views/themes/' . $themeSlug . '/' . ltrim($entrypoint, '/');
        })->all();
        
        return app(Vite::class)($projectRelativeEntrypoints);
    }
}

Chapter 10: Dynamic Provider and Layout Components

The brain of our theming system will be a `ThemeManager` and a `ThemeServiceProvider`. They will determine which theme to load and configure Blade accordingly.

10.1. The `ThemeManager` Service

This service's sole responsibility is to determine the current theme's slug based on a tenant's configuration.

<?php

namespace App\Services;

use App\Models\Setting;
use Illuminate\Support\Facades\Cache;

class ThemeManager
{
    public function getCurrentThemeSlug(): string
    {
        if (!tenancy()->initialized || !tenancy()->tenant) {
            return 'minimalist'; // Default theme for central app or if no tenant
        }

        $cacheKey = 'tenant_theme_' . tenancy()->tenant->getTenantKey();

        return Cache::rememberForever($cacheKey, function () {
            $themeSetting = Setting::where('key', 'theme')->first()?->value;
            return $themeSetting ?? 'minimalist';
        });
    }
}

10.2. The `ThemeServiceProvider`

This provider uses the `ThemeManager` to tell Laravel where to find the active theme's Blade views and components. Don't forget to register it in bootstrap/providers.php.

<?php

namespace App\Providers;

use App\Services\ThemeManager;
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\View;
use Illuminate\Support\ServiceProvider;

class ThemeServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->singleton(ThemeManager::class, function () {
            return new ThemeManager();
        });
    }

    public function boot(): void
    {
        if ($this->app->runningInConsole()) {
            $themeComponentPath = resource_path('views/themes/minimalist/components');
            if (is_dir($themeComponentPath)) {
                Blade::anonymousComponentPath($themeComponentPath, 'theme');
            }
            return;
        }

        View::composer('*', function ($view) {
            $themeManager = $this->app->make(ThemeManager::class);
            $currentTheme = $themeManager->getCurrentThemeSlug();
            $themeViewPath = resource_path('views/themes/' . $currentTheme);
            $themeComponentPath = resource_path('views/themes/' . $currentTheme . '/components');

            if (is_dir($themeViewPath)) {
                $this->app['view']->prependLocation($themeViewPath);
            }

            if (is_dir($themeComponentPath)) {
                Blade::anonymousComponentPath($themeComponentPath, 'theme');
            }
        });
    }
}

10.3. Layout Components

Finally, components like `AppLayout` and `BaseLayout` must be modified to use the `ThemeManager` to render the layout view corresponding to the active theme.

<?php

namespace App\View\Components;

use App\Services\ThemeManager;
use Illuminate\Support\Facades\Blade;
use Illuminate\View\Component;

class AppLayout extends Component
{
    protected ThemeManager $themeManager;
    protected string $themeSlug;

    public function __construct(ThemeManager $themeManager)
    {
        $this->themeManager = $themeManager;
        $this->themeSlug = $this->themeManager->getCurrentThemeSlug();
    }

    public function render()
    {
        $themePath = resource_path('views/themes/' . $this->themeSlug);
        $themeViewsPath = $themePath . '/views';
        $themeComponentsPath = $themePath . '/components';

        if (is_dir($themeViewsPath)) {
            view()->getFinder()->prependLocation($themeViewsPath);
        }

        if (is_dir($themeComponentsPath)) {
            Blade::anonymousComponentPath($themeComponentsPath, 'theme');
        }
        
        return view('layouts.app');
    }
}

Conclusion & Perspectives

Félicitations pour avoir terminé ce parcours sur Laravel Multi-Tenant !

Vous avez exploré en profondeur les concepts nécessaires à la création d'une application SaaS robuste et scalable. De l'initialisation du projet à la modularisation, en passant par la gestion dynamique des thèmes, vous possédez désormais les compétences pour construire des applications complexes et bien structurées. Le voyage ne s'arrête pas là. Continuez à explorer, à expérimenter et à construire. La pratique est la clé de la maîtrise !

Explorez le Code Source

Le code complet de ce projet est disponible sur GitHub. N'hésitez pas à le cloner, l'étudier ou même y contribuer !

Voir sur GitHub