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 :
- L'app dépasse 20-30 entités : la navigation devient difficile
- Plusieurs développeurs : réduire les conflits sur les mêmes fichiers
- Domaines métier distincts : commandes, paiements, inventaire sont logiquement séparés
- Évolution indépendante : certains modules changent plus que d'autres
Pour une petite app avec 5-10 models, la structure par défaut de Laravel reste la meilleure option.
Bonnes pratiques
- Un Service Provider par module : point d'entrée unique et clair
- Communication par events : évite le couplage fort entre modules
- Interfaces partagées dans App\Contracts : pour les dépendances explicites
- Tests dans le module : facilite le refactoring et l'isolation
- Pas de références directes entre models : utilisez les IDs ou des DTOs
- Documentez les dépendances : un README par module listant ses dépendances