Structurer une app Laravel en modules

Quand une application grandit, la structure par défaut de Laravel peut devenir difficile à naviguer. Organiser le code par domaine métier plutôt que par type technique rend l'application plus maintenable et scalable.

Le problème avec la structure par défaut

Laravel propose une structure organisée par type technique : tous les controllers dans app/Http/Controllers, tous les models dans app/Models, etc. C'est parfait pour démarrer, mais pose problème à l'échelle.

                
Structure par défaut (technique)
app/ ├── Http/ │ └── Controllers/ │ ├── OrderController.php │ ├── ProductController.php │ ├── UserController.php │ └── ... (50 fichiers) ├── Models/ │ ├── Order.php │ ├── Product.php │ ├── User.php │ └── ... (30 fichiers) ├── Services/ │ └── ... (20 fichiers) └── ...

Les symptômes du problème

Dossiers géants : 50+ controllers dans le même dossier.
Couplage fort : difficile de voir les dépendances entre domaines.
Onboarding lent : nouveaux devs perdus dans la structure.

L'approche modulaire par domaine

L'idée est de regrouper tout ce qui concerne un domaine métier au même endroit. Chaque module contient ses propres controllers, models, services, routes, vues.

                
Structure modulaire (par domaine)
app/ └── Modules/ ├── Orders/ │ ├── Controllers/ │ │ └── OrderController.php │ ├── Models/ │ │ ├── Order.php │ │ └── OrderItem.php │ ├── Services/ │ │ └── OrderService.php │ ├── Events/ │ ├── Policies/ │ ├── routes.php │ └── OrderServiceProvider.php │ ├── Products/ │ ├── Controllers/ │ ├── Models/ │ ├── Services/ │ ├── routes.php │ └── ProductServiceProvider.php │ └── Users/ ├── Controllers/ ├── Models/ ├── Services/ └── UserServiceProvider.php

Avantages immédiats

Navigation intuitive : tout ce qui concerne les commandes est dans Modules/Orders.
Encapsulation : chaque module peut évoluer indépendamment.
Équipes : différentes équipes peuvent travailler sur différents modules.

Mise en place pas à pas

1. Créer la structure de base

                
Terminal
mkdir -p app/Modules/Orders/{Controllers,Models,Services,Events,Policies}

2. Configurer l'autoloading PSR-4

Ajoutez le namespace de vos modules dans composer.json :

                
composer.json
{ "autoload": { "psr-4": { "App\\": "app/", "Modules\\": "app/Modules/" } } }

Puis regénérez l'autoloader :

                
Terminal
composer dump-autoload

3. Créer le Service Provider du module

Chaque module a son propre Service Provider qui enregistre routes, vues, bindings, et policies.

                
app/Modules/Orders/OrderServiceProvider.php
namespace Modules\Orders; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; use Modules\Orders\Models\Order; use Modules\Orders\Policies\OrderPolicy; class OrderServiceProvider extends ServiceProvider { public function register(): void { // Bindings du container $this->app->singleton( OrderService::class ); } public function boot(): void { // Charger les routes du module $this->loadRoutesFrom(__DIR__.'/routes.php'); // Charger les vues (si nécessaire) $this->loadViewsFrom(__DIR__.'/Views', 'orders'); // Charger les migrations $this->loadMigrationsFrom(__DIR__.'/Migrations'); // Enregistrer les policies Gate::policy(Order::class, OrderPolicy::class); // Observer Order::observe(OrderObserver::class); } }

4. Enregistrer le provider

Ajoutez le provider dans bootstrap/providers.php :

                
bootstrap/providers.php
return [ App\Providers\AppServiceProvider::class, // Modules Modules\Orders\OrderServiceProvider::class, Modules\Products\ProductServiceProvider::class, Modules\Users\UserServiceProvider::class, ];

Routes et Controllers

Chaque module définit ses propres routes dans un fichier dédié :

                
app/Modules/Orders/routes.php
use Illuminate\Support\Facades\Route; use Modules\Orders\Controllers\OrderController; Route::middleware(['web', 'auth'])->group(function () { Route::get('/orders', [OrderController::class, 'index']) ->name('orders.index'); Route::get('/orders/{order}', [OrderController::class, 'show']) ->name('orders.show'); Route::post('/orders', [OrderController::class, 'store']) ->name('orders.store'); }); // Routes API du module Route::middleware(['api', 'auth:sanctum']) ->prefix('api') ->group(function () { Route::apiResource('orders', OrderController::class); });
                
app/Modules/Orders/Controllers/OrderController.php
namespace Modules\Orders\Controllers; use App\Http\Controllers\Controller; use Modules\Orders\Models\Order; use Modules\Orders\Services\OrderService; class OrderController extends Controller { public function __construct( private OrderService $orderService, ) {} public function index() { $orders = $this->orderService->getForCurrentUser(); return view('orders::index', compact('orders')); } public function show(Order $order) { $this->authorize('view', $order); return view('orders::show', compact('order')); } }

Notation des vues

orders::index fait référence à la vue index.blade.php dans le namespace orders défini par loadViewsFrom() dans le Service Provider.

Communication entre modules

Un point crucial : comment les modules interagissent-ils entre eux ? Deux approches recommandées :

1. Events et Listeners

Le pattern le plus découplé. Un module émet un event, d'autres modules peuvent y réagir.

                
app/Modules/Orders/Events/OrderPlaced.php
namespace Modules\Orders\Events; use Modules\Orders\Models\Order; class OrderPlaced { public function __construct( public Order $order, ) {} }
                
app/Modules/Inventory/Listeners/ReserveStock.php
namespace Modules\Inventory\Listeners; use Modules\Orders\Events\OrderPlaced; class ReserveStock { public function handle(OrderPlaced $event): void { foreach ($event->order->items as $item) { $this->inventoryService->reserve( $item->product_id, $item->quantity ); } } }

2. Interfaces partagées

Pour les dépendances plus directes, définissez des interfaces dans un namespace partagé.

                
app/Contracts/ProductCatalogInterface.php
namespace App\Contracts; interface ProductCatalogInterface { public function getProduct(int $id): ?ProductDTO; public function checkAvailability(int $productId, int $quantity): bool; }

Le module Products implémente cette interface, le module Orders l'utilise via injection de dépendance.

Attention aux dépendances circulaires

Si le module A dépend du module B qui dépend du module A, vous avez un problème d'architecture. Utilisez les events pour casser ces cycles.

Structure complète d'un module

Voici la structure recommandée pour un module complet :

                
Module Orders - Structure complète
app/Modules/Orders/ ├── Controllers/ │ ├── OrderController.php │ └── OrderItemController.php ├── Models/ │ ├── Order.php │ └── OrderItem.php ├── Services/ │ ├── OrderService.php │ └── OrderPricingService.php ├── Actions/ │ ├── CreateOrderAction.php │ └── CancelOrderAction.php ├── Events/ │ ├── OrderPlaced.php │ └── OrderCancelled.php ├── Listeners/ │ └── SendOrderConfirmation.php ├── Policies/ │ └── OrderPolicy.php ├── Requests/ │ ├── StoreOrderRequest.php │ └── UpdateOrderRequest.php ├── Resources/ │ └── OrderResource.php ├── DTOs/ │ └── OrderDTO.php ├── Enums/ │ └── OrderStatus.php ├── Migrations/ │ └── 2024_01_01_000000_create_orders_table.php ├── Views/ │ ├── index.blade.php │ └── show.blade.php ├── Tests/ │ ├── Unit/ │ └── Feature/ ├── routes.php └── OrderServiceProvider.php

Actions vs Services

Actions : une opération métier spécifique (CreateOrderAction).
Services : logique réutilisable par plusieurs actions (OrderPricingService).
Vous pouvez utiliser l'un ou l'autre, ou les deux. L'important est la cohérence.

Automatiser avec un package

Pour les projets plus importants, des packages comme nwidart/laravel-modules automatisent la création et la gestion des modules.

                
Terminal
composer require nwidart/laravel-modules # Créer un nouveau module php artisan module:make Orders # Générer des composants dans un module php artisan module:make-controller OrderController Orders php artisan module:make-model Order Orders php artisan module:make-migration create_orders_table Orders

Mon conseil : commencez avec une structure manuelle pour bien comprendre le fonctionnement. Passez à un package si vous avez plus de 5-6 modules et que la maintenance devient lourde.

Quand modulariser ?

La modularisation a un coût en complexité. Elle vaut le coup quand :

Pour une petite app avec 5-10 models, la structure par défaut de Laravel reste la meilleure option.

Bonnes pratiques

Besoin d'aide sur votre projet ?

Je peux vous accompagner dans le développement ou l'optimisation de votre application.

Me contacter