Votre application multi-tenant avec Laravel, sans package tiers


Lorsque nous nous inscrivons ou souscrivons à un service en ligne, il est commun qu'apparaisse dans l'URL de celui-ci une référence qui nous est propre, un identifiant aléatoire ou encore un identifiant choisi, comme un pseudonyme ou un nom d'entreprise.
Cette référence peut être un (ou plusieurs) segments d'URL comme https://mon-service.com/laravel-france
ou un sous-domaine comme https://laravel-france.mon-service.com
.
Si c'est un segment d'URL, il sera généralement en première position et vous pourrez constater qu'il est présent tout au long de votre navigation. C'est là la différence avec une page d'accueil classique où l'URL est la même pour tous les utilisateurs.
Ce choix de design de l'URL d'une application web est souvent le résultat d'une architecture multi-tenant (multitenancy, en anglais).
Chaque client du service (un groupe d'utilisateurs, finalement) est isolé dans son espace de travail - son URL - et pourra personnaliser l'expérience de ses utilisateurs. Tout cela en étant sur le même site web que tous les autres clients du service, peut-être même son concurrent.
Vous l'aurez compris, cette architecture a pour but de nous simplifier la vie lors de la maintenance de l'application, de la gestion des mises à jour et de la gestion des données. Une mise à jour de l'application sera déployée pour tous les clients, et la gestion des données est (généralement) centralisée dans une base de données unique.
Voilà pourquoi les produits SaaS (Software as a Service) en raffolent.
Dans cet article, je vous propose de découvrir la théorie de l'architecture multi-tenant appliquée à Laravel, sans recourir à un package tiers !
Passons à l'action !
Votre mission, si vous l'acceptez, sera aujourd'hui de faire cohabiter dans une même application plusieurs clients (client1
et client2
).
Notre service sera hébergé sur le domaine mon-service.com
et nos clients seront accessibles via les sous-domaines client1.mon-service.com
et client2.mon-service.com
.
Nous nous attarderons principalement sur le routing et la configuration des tenants, je vous laisse imaginer les différentes pages et mécanismes qui vous seront nécessaires pour articuler la théorie avec la pratique.
- Comment identifier le tenant actif ?
- Où intégrer nos tenants dans le routing ?
- Personnaliser l’expérience par tenant
Comment identifier le tenant actif ?
Et tout commence avec notre URL : comment identifier avec Laravel le tenant en cours d'utilisation ?
Laravel peut identifier et router les requêtes faites à un domaine ou les sous-domaines. Lorsque l'on route un sous domaine, il est possible de capturer ce sous-domaine pour l'utiliser comme une variable, cela fonctionne exactement de la même façon qu'avec un segment d'URL.
Si on édite notre fichier routes/web.php
, dans une nouvelle installation de Laravel.
1<?php 2 3use Illuminate\Support\Facades\Route; 4 5Route::domain('mon-service.com') 6 ->get('/', function () { 7 return view('welcome'); 8 }); 9 10Route::domain('{tenant}.mon-service.com')11 ->get('/', function (string $tenant) {12 return $tenant;13 });
Nous pouvons router les requêtes vers notre domaine mon-service.com
et par exemple utiliser ce domaine pour notre site vitrine.
Nous pouvons également router les requêtes vers nos sous-domaines {tenant}.mon-service.com
, l'utilisation de {tenant}
permet la capture du sous domaine renseigné.
Cela fonctionne exactement comme les paramètres glissés dans l'URL à une différence près, toutes les routes associées à ce sous domaine transmettront la variable $tenant
, cela bannit la réutilisation de ce nom de variable dans vos formulaires ou segments d'URL, il faudra donc bien le choisir pour éviter les collisions.
Pour retrouver notre $tenant
dans un controller, encore une fois cela fonctionne de la même manière que pour les segments d'URL.
Généralement, le tenant est l'argument capturé en premier lors du routing, il sera donc en première position après les classes autoloadées.
1<?php 2 3namespace App\Http\Controllers; 4 5use Illuminate\Http\Request; 6use App\Services\MyService; 7 8class DashboardController extends ControllerAbstract 9{10 public function home(Request $request, MyService $MyService, string $tenant, string $segmentUrl1)11 {12 return $tenant;13 }14}
D'ailleurs, vous l'aurez maintenant compris, notre $tenant
n'est rien de plus qu'un paramètre d'URL et donc un paramètre de notre route.
Vous pouvez ainsi retrouver ce paramètre directement dans l'objet Route de votre requête $request->route()->parameter('tenant')
, très pratique dans les middlewares par exemple.
Dans l'éventualité où vous ne partiriez pas d'une nouvelle installation, la documentation Laravel préconise de déclarer les domaines et sous-domaines en premier dans vos fichiers de routes (routes/web.php
par exemple) pour éviter là encore des collisions.
Si vous souhaitez isoler vos routes pour vos tenants, je vous rappelle qu'il est possible d'ajouter des fichiers de routes via bootstrap/app.php
.
1<?php 2 3use Illuminate\Foundation\Application; 4use Illuminate\Support\Facades\Route; 5 6return Application::configure(basePath: dirname(__DIR__)) 7 ->withRouting( 8 web: __DIR__.'/../routes/web.php', 9 api: __DIR__.'/../routes/api.php',10 commands: __DIR__.'/../routes/console.php',11 health: '/up',12 then: function () {13 Route::middleware('web')14 //->name('tenant.')15 ->domain('{tenant}.mon-service.com')16 ->group(base_path('routes/tenant.php'));17 },18 )19 //...
On a fait le plus gros du travail, nous sommes capables de savoir quel tenant est en cours d'utilisation. Maintenant, il faut utiliser ce tenant et le mettre à contribution pour distinguer les actions de chaque tenant.
Où intégrer nos tenants dans le routing ?
Souvenez-vous, nos deux tenants client1
et client2
doivent être isolés ! Il est inenvisageable que client2
voit les données de client1
et vice-et-versa.
Nous pourrions même imaginer personnaliser l'expérience des utilisateurs d'un tenant, même les plateformes Saas peuvent parfois en avoir besoin pour expérimenter des features ou simplement répondre à un besoin vendu par un commercial volontaire face aux désirs d'un client.
Et pour définir le cadre de notre tenant, nous allons surcharger dynamiquement la configuration de notre application.
Petit contexte technique avant de modifier notre configuration. Cette modification doit avoir lieu une fois que le router a déterminé que la route existe bien, et ce, juste avant de l'exécuter.
Le router émet quatre événements Routing
, RouteMatched
, PreparingResponse
et ResponsePrepared
. Ils sont utilisés dans cet ordre pour informer l'application sur l'acheminement de la requête.
Routing
est émis lorsque le router tente de faire correspondre une URL avec une route de l'application. Si une route est trouvée RouteMatched
est envoyé, sinon l'exception (404) NotFoundHttpException
est lancé.
Si les deux étapes précédentes ont trouvé une route, alors l'application émet PreparingResponse
qui annonce l'exécution des middlewares et l'execution du controller concerné. Une fois la réponse construite, c'est au tour de ResponsePrepared
d'annoncé la fin de l'exécution.
Après quelques hésitations entre RouteMatched
et PreparingResponse
qui sont très proches dans le cycle de vie du routing, nous allons utiliser RouteMatched
comme événement de référence pour surcharger la configuration du tenant.
Cet événement indique précisément le moment où la route est attachée à la requête et à ce moment, il n'existe encore pas de réponse, l'exécution peut être interrompue en cas de mauvaise configuration du tenant.
Vous pourriez faire un choix différent, je vous invite à lire le code du Router pour vous faire votre propre idée.
Pour nous inviter dans le cycle de vie du routing, il faut créer un listener app/Listeners/SetupTenantListener.php
qui va écouter notre événement.
1<?php 2 3use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 4 5class SetupTenantListener 6{ 7 public array $tenants = ['client1', 'client2']; 8 9 public function __construct(protected Application $app) {}10 11 public function handle(RouteMatched $event): void12 {13 $tenant = $event->route->parameter('tenant');14 15 if (is_null($tenant)) {16 return;17 }18 19 if (!in_array($tenant, $this->tenants)) {20 throw new NotFoundHttpException();21 }22 23 /*24 * la configuration sera surchargée ici25 */26 }27}
Ce listener va écouter en permanence l'événement RouteMatched
, les premières lignes qui le composent doivent parer l'éventualité que l'URL en cours d'analyse ne le concerne pas, une URL qui ne ferait donc pas partie des tenants pour lesquels nous souhaitons surcharger notre application.
Dans ce cas, nous abandonnons simplement l'exécution du listener.
Si L'URL fait partie des tenants, mais qu'elle ne figure pas dans notre liste de tenant attendu, nous rejetterons la requête. Ici, notre liste de tenants est un tableau, si vous le remplacez par une requête, pensez à mettre le résultat en cache, car ce listener sera sollicité à chaque requête sur votre application.
Une fois ces quelques vérifications faites, nous sommes sûr que la requête en cours concerne un de nos tenants, nous allons pouvoir surcharger la configuration de l'application pour la personnaliser pour le bon fonctionnement du tenant.
Personnaliser l’expérience par tenant
Si l'on doit commencer quelque part, ce sera encore par le routing, le fait que le tenant soit un paramètre à part entière de notre URL va nous obliger à renseigner le tenant dans chaque route.
Vous vous imaginez ajouter le paramètre tenant
à chaque fois que vous créez une URL route('...', ['tenant' => $tenant, 'param2' => $value, ...])
?
Pour s'éviter cette peine, nous allons indiquer au service d'URL de toujours définir le paramètre tenant
à notre place.
1$this->app['url']->defaults(['tenant' => $tenant]);
De la même façon, je vous recommande d'ajouter le nom du tenant dans vos logs, cela pourra vous aider pour reproduire certains incidents.
1Log::withContext(['tenant' => $tenant]);
Parmi les indispensables, le tenant doit vous permettre de filtrer vos données efficacement. Ici, la liste des utilisateurs sera filtrée via un scope pour ne faire apparaitre que les utilisateurs du tenant en cours d'utilisation.
1User::addGlobalScope(new UserScope($tenant));
Vous pourriez choisir de carrément utiliser une base de données distincte par tenant, cela pourrait se faire en surchargeant la configuration $this->app['config']->set('database.default', 'mysql_324234');
.
Ci-dessous, un exemple de surcharge lorsque les configurations du fichier config/services.php
sont utilisées pour surcharger la configuration de l'application.
1$this->app['config']->set('app.name', $tenant);2foreach (config("services.{$tenant}") as $key => $value) {3 $this->app['config']->set($key, $value);4}
Enfin, la partie que votre entreprenant commercial ne doit pas lire. Si vous souhaitez personnaliser l'expérience pour un tenant en particulier. Certains services Laravel permettent d'ajouter des namespaces pour récupérer du contenu dans des répertoires spécifiques.
Ci-dessous, un exemple avec des vues et des traductions.
On peut imaginer que vous ayez des répertoires spécifiques pour vos clients (lang/client1
et resources/views/client1
) dans lesquels vous ayez des spécificités pour ces clients.
1$this->app['translator']->addNamespace('tenant', lang_path($tenant));2$this->app['view']->addNamespace('tenant', resource_path("views/{$tenant}"));
On exploite ensuite le namespace tenant::
(et pas client1::
) défini précédemment pour charger les vues et les traductions spécifiques au tenant.
Ci-dessous, on va récupérer la vue d'une page d’accueil et un titre spécifiques.
1<?php 2 3namespace App\Http\Controllers; 4 5use Illuminate\Http\Request; 6 7class DashboardController extends ControllerAbstract 8{ 9 public function home(Request $request, string $tenant)10 {11 $data = [];12 13 return view('tenant::home', [14 'title' => trans('tenant::home.title'),15 'data' => $data,16 ]);17 }18}
Ci-dessous, le code complet de notre listener.
1<?php 2 3use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; 4 5class SetupTenantListener 6{ 7 public array $tenants = ['client1', 'client2']; 8 9 public function __construct(protected Application $app) {}10 11 public function handle(RouteMatched $event): void12 {13 $tenant = $event->route->parameter('tenant');14 15 if (is_null($tenant)) {16 return;17 }18 19 if (!in_array($tenant, $this->tenants)) {20 throw new NotFoundHttpException();21 }22 23 $this->app['url']->defaults(['tenant' => $tenant]);24 25 Log::withContext(['tenant' => $tenant]);26 27 User::addGlobalScope(new UserDirectoryScope($tenant));28 29 // Configuration spécifique au tenant30 $this->app['config']->set('app.name', $tenant);31 foreach (config("services.{$tenant}") as $key => $value) {32 $this->app['config']->set($key, $value);33 }34 35 $this->app['translator']->addNamespace('tenant', lang_path($tenant));36 $this->app['view']->addNamespace('tenant', resource_path("views/{$tenant}"));37 }38}
Nous y voilà ! Nous avons implémenté une application multi-tenant avec Laravel, sans package tiers !
Comme vous l'avez surement constaté durant votre lecture, cette architecture repose sur une combinaison de mécanismes simples de Laravel. L'implémentation dans cet article n'est pas exhaustive et pourrait être viable en production, néanmoins d'autres spécialistes se sont penchés sur le sujet et le couvrent avec bien plus de fonctionnalités comme Spatie avec son package laravel-multitenancy.
À vous de tester maintenant, vous devriez essayer de créer un espace de login utilisateur pour plusieurs sous-domaines au sein d'une même base de données.
A lire
Autres articles de la même catégorie

Effectuer rapidement des benchmarks dans Laravel
Voyons comment le nouveau service Benchmark de Laravel fonctionne !

Mathieu De Gracia

Optimisez votre application avec le chunk de Laravel
Voyons ce que propose Laravel pour traiter de grandes quantités de données efficacement

Mathieu De Gracia

Comment développer un paquet en local ?
Vous souhaitez ajouter une fonctionnalité à votre application via un paquet ? Voici un tour d’horizon de la phase de développement.

William Suppo