Kevin Aubrée

Blog / · 9 min de lecture

Clean Architecture avec Laravel : le guide complet pour arrêter de tout mettre dans Eloquent

Laravel donne une vitesse énorme au début. La Clean Architecture devient utile quand le métier dépasse le CRUD : actions, DTO, ports, repositories, tests rapides, migration progressive et limites à connaître.

Clean Architecture avec Laravel : le guide complet pour arrêter de tout mettre dans Eloquent

Laravel ne t’oblige pas à écrire du code sale.

Il te laisse juste aller très vite. Trop vite, parfois. Un controller reçoit une requête, un FormRequest valide, un model Eloquent persiste, une notification part, une policy vérifie un droit, une resource transforme la réponse. Pour un CRUD simple, c’est parfait.

Le problème commence quand ce CRUD devient un vrai domaine métier. Les règles changent, les cas limites s’accumulent, les tests deviennent lents, et le model Eloquent finit par contenir de la persistance, de l’autorisation, de la facturation, du pricing, des transitions d’état et trois intégrations externes.

La Clean Architecture n’est pas une religion. C’est une manière de protéger le coeur métier quand Laravel n’est plus seulement un framework de livraison, mais l’enveloppe autour d’un produit qui doit durer.

Le symptôme : Laravel MVC devient un couloir trop étroit

Le MVC Laravel classique tient très bien tant que l’application reste proche de la base de données.

Tu as une table orders, un OrderController, un Order Eloquent model, quelques validations, une resource JSON. Le coût d’abstraction serait plus élevé que le problème.

Puis le métier arrive :

  • une commande ne peut être annulée que dans certains états;
  • un client B2B a des règles de paiement différentes;
  • certains produits déclenchent une vérification de stock externe;
  • l’email ne doit partir qu’après commit;
  • l’admin peut forcer une transition mais pas depuis l’API publique;
  • une facture doit être générée selon un calendrier légal.

Si tout ça finit dans Order.php, le model grossit. Si tu le sors dans OrderService, le service grossit. Si tu le répartis entre controller, observers, jobs et events, personne ne sait plus où vit la règle.

La Clean Architecture répond à une question simple : quelle partie du code doit rester vraie même si demain tu remplaces Eloquent, ton provider de paiement ou ton transport HTTP ?

Le principe qui compte vraiment

Le principe n’est pas “mettre des dossiers Domain, Application, Infrastructure parce qu’un schéma l’a dit”.

Le principe est l’inversion de dépendance :

  • le domaine ne dépend pas de Laravel;
  • les cas d’usage ne dépendent pas d’Eloquent;
  • les controllers adaptent HTTP vers l’application;
  • l’infrastructure implémente les ports attendus par l’application;
  • le container Laravel assemble le tout.

Laravel reste très présent. Son service container, ses service providers, ses Form Requests, ses policies, ses jobs, ses resources et ses tests HTTP restent utiles. Mais ils restent sur les bords.

Une structure pragmatique

Tu peux garder une structure Laravel classique et ajouter une séparation par module.

app/
  Domain/
    Order/
      Order.php
      OrderId.php
      OrderStatus.php
      OrderLine.php
      OrderMapper.php
      CustomerId.php
      Exception/
        OrderCannotBeCancelled.php
        EmptyOrder.php
        OrderNotFound.php
  Application/
    Order/
      PlaceOrder/
        PlaceOrderAction.php
        PlaceOrderCommand.php
      CancelOrder/
        CancelOrderAction.php
        CancelOrderCommand.php
      Port/
        OrderRepository.php
        PaymentGateway.php
  Infrastructure/
    Order/
      Persistence/
        EloquentOrderRepository.php
        OrderModel.php
      Payment/
        StripePaymentGateway.php
  Http/
    Controllers/
      OrderController.php
    Requests/
      PlaceOrderRequest.php
    Resources/
      OrderResource.php

Ce n’est pas la seule structure possible. L’important est que le sens des dépendances reste clair : HTTP et Eloquent peuvent connaître l’application, mais le domaine ne connaît pas HTTP ni Eloquent.

Domaine : PHP pur, règles explicites

Le domaine contient les invariants. Pas les requêtes SQL. Pas les payloads HTTP. Pas les helpers Laravel.

<?php

namespace App\Domain\Order;

use App\Domain\Order\Exception\EmptyOrder;
use App\Domain\Order\Exception\OrderCannotBeCancelled;

final class Order
{
    /** @param list<OrderLine> $lines */
    private function __construct(
        private readonly OrderId $id,
        private readonly CustomerId $customerId,
        private OrderStatus $status,
        private array $lines,
    ) {}

    /** @param list<OrderLine> $lines */
    public static function place(OrderId $id, CustomerId $customerId, array $lines): self
    {
        if ($lines === []) {
            throw new EmptyOrder();
        }

        return new self($id, $customerId, OrderStatus::Pending, $lines);
    }

    /** @param list<OrderLine> $lines */
    public static function reconstitute(
        OrderId $id,
        CustomerId $customerId,
        OrderStatus $status,
        array $lines,
    ): self {
        return new self($id, $customerId, $status, $lines);
    }

    public function cancel(): void
    {
        if ($this->status !== OrderStatus::Pending) {
            throw new OrderCannotBeCancelled($this->id, $this->status);
        }

        $this->status = OrderStatus::Cancelled;
    }

    public function id(): OrderId
    {
        return $this->id;
    }

    /** @return list<OrderLine> */
    public function lines(): array
    {
        return $this->lines;
    }
}

Ce code se teste sans Laravel, sans database, sans factory Eloquent. C’est le but. Quand une règle métier casse, tu veux un test de domaine qui échoue en 20 millisecondes, pas un test HTTP qui boot toute l’application.

Application : les actions orchestrent

La couche application porte les cas d’usage. Dans Laravel, les Actions sont souvent le format le plus lisible : une classe, une intention, une méthode publique.

<?php

namespace App\Application\Order\PlaceOrder;

use App\Application\Order\Port\OrderRepository;
use App\Application\Order\Port\PaymentGateway;
use App\Domain\Order\CustomerId;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;
use App\Domain\Order\OrderLine;

final readonly class PlaceOrderAction
{
    public function __construct(
        private OrderRepository $orders,
        private PaymentGateway $payments,
    ) {}

    public function execute(PlaceOrderCommand $command): OrderId
    {
        $lines = array_map(
            static fn (array $line): OrderLine => OrderLine::fromArray($line),
            $command->lines,
        );

        $order = Order::place(
            OrderId::new(),
            new CustomerId($command->customerId),
            $lines,
        );

        $this->orders->save($order);
        $this->payments->authorize($order->id());

        return $order->id();
    }
}

L’action ne sait pas si la sauvegarde passe par Eloquent, PostgreSQL, une API interne ou un fake en mémoire. Elle exprime le scénario.

Note importante : l’appel paiement est volontairement après la sauvegarde. En production, je préfère encore un outbox ou un job afterCommit idempotent pour éviter le trou “paiement autorisé mais ordre non persisté”. Le snippet montre le sens des dépendances, pas une stratégie de paiement complète.

Le piège : transformer l’action en service géant. Si PlaceOrderAction commence à contenir cancel, refund, ship, archive, tu as juste déplacé le problème.

DTO : stabiliser l’entrée du use case

Un controller reçoit du HTTP. Une action reçoit une commande d’application.

<?php

namespace App\Application\Order\PlaceOrder;

final readonly class PlaceOrderCommand
{
    /** @param list<array{sku: string, quantity: int}> $lines */
    public function __construct(
        public string $customerId,
        public array $lines,
    ) {}
}

Le DTO évite de faire circuler Request, tableaux non typés et conventions HTTP dans le coeur applicatif.

Dans Laravel, le FormRequest reste excellent pour valider le bord HTTP.

<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

final class PlaceOrderRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'customer_id' => ['required', 'uuid'],
            'lines' => ['required', 'array', 'min:1'],
            'lines.*.sku' => ['required', 'string'],
            'lines.*.quantity' => ['required', 'integer', 'min:1'],
        ];
    }
}

Mais une fois la validation HTTP passée, tu construis un objet d’application.

$command = new PlaceOrderCommand(
    customerId: (string) $request->string('customer_id'),
    lines: $request->array('lines'),
);

Infrastructure : Eloquent devient un adaptateur

Eloquent n’est pas l’ennemi. Le problème, c’est de le prendre pour le domaine.

Dans une architecture propre, Eloquent sert à parler à la base. Le domaine sert à exprimer le métier.

<?php

namespace App\Application\Order\Port;

use App\Domain\Order\Order;
use App\Domain\Order\OrderId;

interface OrderRepository
{
    public function get(OrderId $id): Order;

    public function save(Order $order): void;
}

Implémentation Laravel :

<?php

namespace App\Infrastructure\Order\Persistence;

use App\Application\Order\Port\OrderRepository;
use App\Application\Order\Exception\OrderNotFound;
use App\Domain\Order\Order;
use App\Domain\Order\OrderId;

final readonly class EloquentOrderRepository implements OrderRepository
{
    public function get(OrderId $id): Order
    {
        $model = OrderModel::query()->with('lines')->find($id->toString());

        if (!$model instanceof OrderModel) {
            throw new OrderNotFound($id);
        }

        return OrderMapper::toDomain($model);
    }

    public function save(Order $order): void
    {
        $model = OrderModel::query()->find($order->id()->toString()) ?? new OrderModel();
        OrderMapper::fillModel($model, $order);
        $model->save();

        OrderLineModel::syncForOrder($model, $order->lines());
    }
}

Tu paies un mapping. C’est le coût réel de cette architecture. Si ton domaine est simple, ce coût est inutile. Si ton domaine est durable et chargé en règles, ce coût achète de la testabilité et de la stabilité.

Le mapper doit gérer les deux sens : toDomain() pour reconstruire un objet depuis la base via Order::reconstitute(), et fillModel() pour mettre à jour une ligne existante sans créer un doublon de clé primaire.

Les lignes sont synchronisées après le save() du parent, parce qu’Eloquent ne persiste pas automatiquement une collection de value objects du domaine. Dans un vrai code, syncForOrder() peut supprimer les anciennes lignes, recréer les nouvelles dans une transaction, ou faire un diff plus fin si l’historique l’exige.

Binding Laravel : le container assemble

Laravel est très bon pour résoudre des dépendances par constructeur. Tu relies les ports aux adaptateurs dans un service provider.

<?php

namespace App\Providers;

use App\Application\Order\Port\OrderRepository;
use App\Application\Order\Port\PaymentGateway;
use App\Infrastructure\Order\Payment\StripePaymentGateway;
use App\Infrastructure\Order\Persistence\EloquentOrderRepository;
use Illuminate\Support\ServiceProvider;

final class OrderServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(OrderRepository::class, EloquentOrderRepository::class);
        $this->app->bind(PaymentGateway::class, StripePaymentGateway::class);
    }
}

C’est ici que Laravel connaît les détails. Pas dans le domaine.

Controller : fin, ennuyeux, stable

Le controller doit redevenir un adaptateur HTTP. Il valide, traduit, appelle, répond.

<?php

namespace App\Http\Controllers;

use App\Application\Order\PlaceOrder\PlaceOrderAction;
use App\Application\Order\PlaceOrder\PlaceOrderCommand;
use App\Http\Requests\PlaceOrderRequest;
use Illuminate\Http\JsonResponse;

final readonly class OrderController
{
    public function store(PlaceOrderRequest $request, PlaceOrderAction $action): JsonResponse
    {
        $orderId = $action->execute(new PlaceOrderCommand(
            customerId: (string) $request->string('customer_id'),
            lines: $request->array('lines'),
        ));

        return response()->json(['id' => $orderId->toString()], 201);
    }
}

Si ce controller grossit, c’est souvent le signe qu’une règle est au mauvais endroit.

Tests : le vrai bénéfice

Le plus gros gain n’est pas l’élégance. C’est la vitesse de feedback.

Test de domaine :

public function test_shipped_order_cannot_be_cancelled(): void
{
    $order = OrderBuilder::shipped();

    $this->expectException(OrderCannotBeCancelled::class);

    $order->cancel();
}

Test d’action :

public function test_place_order_saves_before_authorizing_payment(): void
{
    $orders = new InMemoryOrderRepository();
    $payments = new FakePaymentGateway();

    $action = new PlaceOrderAction($orders, $payments);

    $orderId = $action->execute(new PlaceOrderCommand(
        customerId: '0c69d6b0-d8b5-4d3b-91d3-c092b5fd27ef',
        lines: [['sku' => 'BOOK-1', 'quantity' => 1]],
    ));

    self::assertTrue($payments->wasAuthorizedFor($orderId));
    self::assertTrue($orders->has($orderId));
}

Tu gardes aussi des tests Laravel HTTP, mais ils deviennent moins nombreux. Ils vérifient le câblage, la validation, l’auth, la sérialisation JSON. Ils ne portent plus toute la logique métier.

Migration progressive

Ne fais pas un big bang.

Plan réaliste :

  1. Choisis un module douloureux, pas tout le projet.
  2. Écris les tests autour du comportement actuel.
  3. Extrais une action depuis le controller ou le service existant.
  4. Remplace les tableaux par des DTO.
  5. Crée un port uniquement quand tu as un vrai besoin de découplage.
  6. Déplace les invariants métier dans un objet de domaine.
  7. Laisse Eloquent tranquille pour les modules CRUD simples.

La Clean Architecture doit réduire le risque. Si elle bloque la livraison pendant trois semaines, tu l’as utilisée comme une refonte, pas comme une stratégie de reprise de contrôle.

Quand ne pas l’utiliser

Reste sur Laravel classique si :

  • tu fais un admin CRUD;
  • le produit est un MVP jetable;
  • les règles métier tiennent dans les validations;
  • l’équipe ne maîtrise pas encore le framework;
  • le mapping coûte plus cher que les bugs évités.

Utilise une séparation forte si :

  • le domaine change souvent;
  • les tests sont trop lents;
  • Eloquent contient des règles métier critiques;
  • tu as plusieurs entrées pour le même use case : API, console, job, webhook;
  • tu dois isoler paiement, facturation, disponibilité, pricing, stock ou droits.

Le bon niveau d’architecture dépend de la douleur. Pas du diagramme.

Sources

Kevin Aubrée

Continuer la lecture

Retour au blog