Une architecture modulaire en Laravel

Publié le 28 septembre 2022 par Mathieu De Gracia
Couverture de l'article Une architecture modulaire en Laravel

Sommaire

  1. La problématique
  2. Principe
  3. Notre proposition
  4. Les contraintes
  5. Demo
  6. Conclusion

La problématique

Laravel dispose d’une architecture de dossier adéquat pour les projets de petite à moyenne envergure qui répondra intelligemment à la plupart de vos besoins.

Cependant, si votre projet continue de grossir, il est probable que vous soyez confronté à une problématique classique de ce type d’architecture.

Prenons pour exemple l’application suivante, elle possède quelques controllers, des models, une commande ainsi que des services.

Untitled

Dans cette situation, les quelques dossiers de Laravel se partagent l’intégralité des fichiers de votre application, tout est mélangé sans grande distinction.

Bien que le nombre de fichiers soit encore relativement faible il est difficile d’identifier rapidement les fonctionnalités de ce projet.

Au fil du temps, ces dossiers pourraient contenir des dizaines voir des centaines de fichiers … l’architecture de votre application deviendra alors sclérosée et très laborieuse à parcourir.

Ce mélange opaque de fichiers sera également pénible à maintenir et nuira probablement à la compréhension de votre code.

La lisibilité est une pierre angulaire de la qualité, tout ce qui nuit à la lisibilité doit immédiatement attirer votre attention : ce qui est lu aisément est compris plus facilement.

Le code est lu beaucoup plus souvent qu'il n'est écrit.

Voyons désormais ce que propose une architecture modulaire pour réimaginer l’organisation de nos projets.

Principe d’une architecture modulaire

Le principe d'une architecture modulaire consiste à découper puis à regrouper les fichiers de votre projet par aspect métier.

Attention, le modulaire n'est pas du DDD, le Domain Driven Design est une façon de modéliser le métier dans votre code, le modulaire est avant tout une restructuration de l'architecture de votre projet.

Le modulaire est un pattern relativement simple à prendre en main contrairement au DDD.

En reprenant notre exemple précédent nous pouvons identifier 4 de ces besoins métier : une gestion des utilisateurs, des profils, des carts et enfin de l’authentification.

Untitled

Une fois ces besoins clairement identifiés nous pouvons nous lancer dans la réorganisation du projet pour mettre en place une architecture modulaire.

Notre proposition

Ce tuto se base sur un Laravel 9

Notre proposition d’architecture modulaire consiste à détourner les fonctionnalités proposées par Laravel pour créer des packages afin d’en faire des modules.

Chaque module contiendra sa propre architecture ainsi qu'un provider pour référencer toutes les ressources accessibles au reste du framework comme les routes, les configs, commands, lang, etc …

Untitled

L'un des avantages de cette proposition est son faible impact sur la structure de Laravel, notre architecture modulaire s'additionne à l'existant et ne perturbe pas ou peu le framework, cette solution est donc viable pour un projet à long terme qui devra suivre les futures évolutions du framework.

Le Provider

Le provider sera la pièce maitresse des modules, c'est lui qui permettra aux modules de communiquer avec Laravel, le provider se trouvera toujours à la racine du module, dans cas de notre exemple : app\Modules\User\UserProvider.php

1namespace App\Modules\User;
2 
3use Illuminate\Support\ServiceProvider;
4 
5class UserProvider extends ServiceProvider
6{
7 public function register()
8 {
9 //
10 }
11 
12 public function boot()
13 {
14 //
15 }
16}

Ce provider sera on ne peut plus classique et devra être référencé dans le config/app.php comme n'importe quel provider.

1'providers' => [
2 
3 [...]
4 
5 /*
6 * Modules Service Providers...
7 */
8 App\Modules\User\UserProvider::class,
9],

Voyons désormais comment utiliser ce provider pour référencer les ressources de notre module !

Des configurations

Chaque module possède son propre fichier de configurations placé à sa racine : app\Modules\User\config.php.

1return [
2 'key' => 'value',
3];

Ensuite, dans notre UserProvider, la méthode mergeConfigFrom permettra de merge ce fichier de configuration.

1public function register()
2{
3 $this->mergeConfigFrom(
4 __DIR__ . '/config.php', 'user',
5 );
6}

Le fichier de configuration du module est désormais accessible dans votre application.

1echo config('user.key'); // value

Des routes

Les routes d'un module seront placées à la racine de ce dernier, dans notre cas dans le fichier app\Modules\User\routes.php.

1use App\Modules\User\Controllers\UserController;
2 
3Route::prefix('user')
4 ->as('user::')
5 ->group(function () {
6 Route::get('home', [UserController::class, 'home'])->name('home');
7 });

Lors de la réalisation de cette application nous avons pris l'habitude de préfixer les routes avec le nom du module nous permettant de clairement les distinguer par la suite : route('user::home').

Un seul fichier de route sera suffisant pour la plupart des modules.

Désormais, il est nécessaire de charger ces routes dans l'application depuis le UserProvider grace à la méthode loadRoutesFrom.

1public function boot()
2{
3 $this->loadRoutesFrom(__DIR__ . '/routes.php');
4}

Les routes de votre module sont maintenant accessibles : http://localhost/user/home

Des traductions

Plusieurs options s'offrent à nous concernant le chargement des traductions d'un module.

La première, la méthode loadTranslationsFrom prendra en argument le dossier contenant les différentes traductions ainsi qu'un namespace.

1public function boot()
2{
3 $this->loadTranslationsFrom(
4 path: __DIR__ . '/langs',
5 namespace: 'user',
6 );
7}

L'architecture des dossiers sera la suivante :

1app/Modules/User/langs/
2 en/
3 core.php
4 fr/
5 core.php

Le namespace sera utile afin de différencier l'origine des traductions :

1Lang::get('user::core.welcome');

La seconde option serait d'utiliser la méthode loadJsonTranslationsFrom permettant de renseigner un fichier json contenant toutes les traductions, l'architecture des dossiers serait un peu différente :

1app/Modules/User/langs/
2 fr.json
3 en.json

La principale différence avec la première option sera l'absence du namespace.

Si vous optez pour cette solution, toutes les localisations de tous vos modules seront mélangées sans distinction, cela pourrait générer des conflits si deux modules possède la même clé de traduction.

Des commandes

Une méthode commands est disponible pour référencer les commandes d'un module au reste du framework.

1use App\Modules\User\Commands\UpdateUserCommand;
2 
3public function boot()
4{
5 $this->commands([
6 UpdateUserCommand::class,
7 ]);
8}

Malheureusement, il n'existe pas de méthode pour référencer automatiquement toutes les commandes d'un dossier.

Il sera preferable de préfixer la signature de la méthode avec le nom du module afin de conserver une distinction visuelle à l'affichage des commandes depuis un php artisan.

1class UpdateUserCommand extends Command
2{
3 /**
4 * The name and signature of the console command.
5 *
6 * @var string
7 */
8 protected $signature = 'user:update-user';

Si l'exécution d'une commande provenant d'un module doit être automatisée, vous serez contraint d'utiliser un callAfterResolving pour récupérer une instance de Schedule depuis le provider du module.

1use Illuminate\Console\Scheduling\Schedule;
2 
3public function boot()
4{
5 $this->callAfterResolving(Schedule::class, function (Schedule $schedule) {
6 $schedule->command(UpdateUserCommand::class)->everyMinute();
7 });
8}

Si vous le souhaitez, il est toujours envisageable de configurer le schedule en passant par le fichier app\Console\Kernel.php comme n'importe qu'elle commande.

Des gates

Une Gate provenant d'un module peut être référencée depuis le fichierapp\Providers\AuthServiceProvider.php ou directement depuis le provider du module à l'aide du service Gate.

1use Illuminate\Contracts\Auth\Access\Gate;
2 
3public function boot()
4{
5 app(Gate::class)
6 ->policy(Model::class, ModelPolicy::class);
7}

Les contraintes

Cette architecture apporte avec elle tout un lot de possibilités mais également plusieurs contraintes, voyons les plus importantes d'entre elles.

Les interconnexions

Notre philosophie autour de cette architecture était de rendre les modules les plus indépendants possible, de limiter au maximum les interactions entre modules.

De facto, les modules sont dépendants envers Laravel, ce qui n'est pas problématique en soi car le framework est la base, le coeur, de l'application.

Les interconnexions entre modules sont cependant plus problématiques.

Un module doit-il avoir connaissance des autres modules de l'application, est-ce vraiment préjudiciable que les modules communiquent entre eux, faut-il restreindre ces dépendances ?

Si ces dépendances sont ineluctable, faut-il encadrer ces connexions à travers des gateway, utiliser des interfaces pour profiter d'une abstraction ?

Toutes ces questions sont encore en suspens et les réponses dépendront de votre sensibilité, un pattern n'est fondamentalement pas bon ou mauvais il apporte simplement des possibilités et des contraintes différentes, à vous de d'évaluer les compromis à effectuer.

Nous avons construit cette architecture pour isoler les features de l'application et s'éloigner d'une application "sac de nœuds", un trop grand nombre d'interconnexions pourraient s'avérer contre-productif et augmenter la complexité de l'application.

Database

Par simplicité nous avons fait le choix de conserver les migrations dans le dossier d'origine /database, toutes les migrations des modules sont donc présentes dans le même dossier.

Ce choix n'est pas encore définitif, vous pouvez si vous le souhaitez intégrer les migrations dans les modules depuis le provider grâce à la méthode loadMigrationsFrom.

1public function boot()
2{
3 $this->loadMigrationsFrom(__DIR__ . '/migrations');
4}

Cependant, vous serez contraint de renseigner l'argument --path= lors de l’exécution de la commande make:migration rendant son utilisation un peu plus laborieuse.

1php artisan make:migration create_user_tables --path=/app/Modules/User/migrations

Cette problématique est commune à toutes les commandes make

Les factories des models exigent également un soin particulier, qu'importe le dossier où elles se trouvent, vous serez contraint de les renseigner manuellement au sein des models avec la méthode newFactory :

1class User extends Authenticatable
2{
3 [...]
4 
5 protected static function newFactory()
6 {
7 return \Database\Factories\UserFactory::new();
8 }
9}

Les tests

Tout comme pour la Database, nous avons décidé de conserver tous les tests dans leur dossier d'origine pour simplifier le workflow d'utilisation de phpunit.

Une simple réorganisation du dossier /tests fut menée pour cloisonner les tests des différents modules.

1tests/
2 Feature/
3 User/
4 FeatureUserTest.php
5 Unit/
6 User/
7 UnitUserTest.php

Démultiplier les dossiers /tests au sein des modules était envisageable mais aurait complexifié le phpunit.xml de notre projet sans que cela soit véritablement justifié.

Demo

Vous trouverez un exemple de cette architecture via le lien du code source en bas de page.

Conclusion

Cette proposition d'architecture modulaire est encore jeune et émane d'un premier retour d'expérience sur un projet de petite envergure d'environ 5'000 lignes.

Cette application possède à ce jour 4 modules de 100 à 900 lignes, 90% de notre logique métier se trouve à l'intérieur d'un module.

Grâce aux modules, le code de cette application est désormais mieux segmenté, les grandes fonctionnalités transparaissent facilement et la lecture et la compréhension du code en sont grandement facilitées.

Avec du recul, la principale difficulté de la mise en place de cette architecture modulaire se trouvait en amont du code, imaginez les différents modules demandent un véritable effort de conception et une vision relativement claire du projet.

Dans le cas d'un nouveau projet, il sera très difficile d'imaginer votre application en module si vous avancez à tâtons sur ses fonctionnalités !

Durant la phase de conception une question reviendra à de nombreuses reprises : "ma nouvelle feature est-elle suffisamment proche d'un module existant ou au contraire mérite d'avoir son propre module ?"

Toute cette réflexion est laborieuse, couteuse en temps et nécessite le concert de l'ensemble de l'équipe ... pour autant, cette réflexion est saine et permettra d'appréhender la construction de votre application bien plus sereinement.

Sur une application existante, se lancer dans une restructuration en architecture modulaire est bien plus aisée, regardez votre code, identifiez les features ... et vous verrez peut-être les prémices de vos futurs modules !

Source : https://github.com/laravel-fr/support-modular-architecture
Mathieu De Gracia avatar
Mathieu De Gracia
Des fois, mon chat code à ma place

A lire

Autres articles de la même catégorie