Clean Architecture avec Laravel : Sortir du MVC Classique
Laravel est un framework extraordinaire pour démarrer vite. Mais cette vélocité initiale a un coût : après 6 mois, les projets MVC classiques deviennent des monolithes où la logique métier est éparpillée entre Models, Controllers, et des “Services” fourre-tout.
La Clean Architecture offre une sortie de secours. Pas une réécriture complète — une migration progressive vers un code structuré, testable et maintenable.
Les limites du MVC classique avec Laravel
Le MVC de Laravel encourage trois anti-patterns :
1. Fat Models. Eloquent pousse à mettre la logique dans les Models. Scopes, accessors, mutators, relations, validations — tout s’accumule dans une classe qui devient ingérable à 500 lignes.
2. Fat Controllers. Quand le Model ne suffit plus, la logique migre dans le Controller. On y trouve de la validation, de la transformation, des appels API, des notifications — le contrôleur fait tout sauf contrôler.
3. Services fourre-tout. La première réaction est de créer un OrderService avec 30 méthodes publiques. C’est un God Object déguisé en service.
Le résultat : un code couplé au framework, impossible à tester sans base de données, et où chaque modification risque de casser quelque chose d’inattendu.
Pour une vision d’ensemble des patterns architecturaux et savoir quand les utiliser, consultez mon guide sur l’architecture logicielle pour applications web modernes.
Les couches de la Clean Architecture
La couche Domain
Le domaine contient la logique métier pure. Pas de dépendance à Laravel, Eloquent, ou quoi que ce soit d’externe.
// src/Domain/Order/Order.php
final class Order
{
private OrderStatus $status;
private array $items = [];
public static function place(CustomerId $customerId, array $items): self
{
if (empty($items)) {
throw new EmptyOrderException();
}
// ...logique métier
}
public function cancel(): void
{
if ($this->status !== OrderStatus::Pending) {
throw new OrderCannotBeCancelledException($this->status);
}
$this->status = OrderStatus::Cancelled;
}
}
Les entités du domaine utilisent des Value Objects (CustomerId, OrderStatus), lèvent des exceptions métier, et ne connaissent ni Illuminate\Database ni Request.
La couche Application
La couche Application orchestre les use cases. C’est ici qu’on trouve les Actions — des classes à responsabilité unique.
// src/Application/Order/PlaceOrderAction.php
final readonly class PlaceOrderAction
{
public function __construct(
private OrderRepositoryInterface $orders,
private PaymentGatewayInterface $payments,
) {}
public function execute(PlaceOrderDTO $dto): Order
{
$order = Order::place(
new CustomerId($dto->customerId),
$dto->items,
);
$this->orders->save($order);
$this->payments->authorize($order);
return $order;
}
}
L’Action dépend d’interfaces (ports), pas d’implémentations. Le conteneur Laravel injecte les implémentations concrètes.
La couche Infrastructure
L’infrastructure contient les adaptateurs : Eloquent Repositories, HTTP Controllers, API clients, etc.
// src/Infrastructure/Persistence/EloquentOrderRepository.php
final class EloquentOrderRepository implements OrderRepositoryInterface
{
public function save(Order $order): void
{
OrderModel::updateOrCreate(
['id' => $order->id()->value()],
$order->toArray(),
);
}
}
Le Controller devient ultra-léger :
public function store(PlaceOrderRequest $request, PlaceOrderAction $action): JsonResponse
{
$order = $action->execute(PlaceOrderDTO::fromRequest($request));
return response()->json(OrderResource::make($order), 201);
}
Migration progressive depuis un projet existant
On ne réécrit pas tout d’un coup. La stratégie :
- Identifier le module le plus douloureux. Celui avec le plus de bugs, le plus de logique dans les controllers.
- Extraire les DTOs. Remplacer les tableaux associatifs par des objets typés.
- Créer les Actions. Déplacer la logique des controllers/models vers des Actions dédiées.
- Introduire les interfaces. Abstraire Eloquent derrière des Repository interfaces.
- Déplacer le domaine. Extraire les règles métier dans des entités de domaine pures.
Chaque étape apporte de la valeur immédiatement. Pas besoin d’attendre la fin de la migration.
Tests unitaires avec la Clean Architecture
La Clean Architecture rend les tests unitaires triviaux :
public function test_cannot_cancel_shipped_order(): void
{
$order = OrderFactory::shipped();
$this->expectException(OrderCannotBeCancelledException::class);
$order->cancel();
}
Pas de RefreshDatabase, pas de DatabaseTransactions. Le test s’exécute en millisecondes parce qu’il ne touche que du PHP pur.
Pour les tests d’intégration et E2E qui complètent cette pyramide, voyez mon article sur les tests E2E avec Playwright.
Les Actions se testent avec des fakes :
public function test_place_order_authorizes_payment(): void
{
$fakePayments = new FakePaymentGateway();
$action = new PlaceOrderAction(new InMemoryOrderRepository(), $fakePayments);
$action->execute(new PlaceOrderDTO('customer-1', [['sku' => 'ITEM-1']]));
$this->assertTrue($fakePayments->wasAuthorizedFor('customer-1'));
}
Retour d’expérience
Après avoir appliqué la Clean Architecture sur 4 projets Laravel de tailles différentes, voici ce que j’ai observé :
Les gains :
- Tests unitaires 10x plus rapides (pas de base de données)
- Onboarding facilité (chaque Action est un point d’entrée clair)
- Refactoring sans peur (les tests de domaine couvrent les invariants)
- Migration de framework possible (on l’a fait de Laravel à Symfony sur un projet)
Les coûts :
- Plus de fichiers et de classes (mais moins de lignes par fichier)
- Mapping Eloquent ↔ Domain à maintenir
- Courbe d’apprentissage pour les devs habitués au tout-Eloquent
Le verdict : si votre projet dépasse 6 mois de vie et implique de la logique métier non triviale, la Clean Architecture paie. Pour un MVP ou un outil interne, le MVC classique reste parfaitement valable.
Le même raisonnement s’applique à Symfony avec CQRS et Messenger — les deux approches partagent les mêmes principes d’isolation et de testabilité.
Kevin De Vaubree
Développeur Full-Stack Senior