Clean Architecture en PHP : guide pratique avec Laravel

Abdoul Kadri ASSOUMANE MAMANE
Abdoul Kadri ASSOUMANE MAMANE Auteur
15 min de lecture
1,707 vues

Écouter l'article

Clean Architecture en PHP : guide pratique avec Laravel

Prenons un projet Laravel devenu difficile à maintenir : les controllers font 300 lignes, la logique métier est éparpillée entre les models, les services et même certaines vues, et l'on hésite à toucher un fichier sans savoir ce que ça va casser ailleurs.

La Clean Architecture de Robert C. Martin propose une réponse à ce problème. Elle n'est pas gratuite : elle a un coût réel, et elle ne convient pas à tous les projets. Voici ce qu'elle apporte, ce qu'elle coûte, et comment la mettre en place avec Laravel.

Précision : il s'agit d'un exemple pédagogique, pas du récit d'un projet réel. Le portfolio qui héberge ce blog reste en MVC Laravel classique, avec une couche app/Services, et ne contient ni dossier app/Domain ni src/. Le code ci-dessous est écrit pour l'article, exécuté sur Laravel 10.50 avec SQLite (puis rejoué sur Laravel 11.57 et 12.69, même résultat). Les noms suivent les conventions des autres articles de la série Laravel : XxxRepositoryInterface, EloquentXxxRepository, StoreXxxRequest pour la validation HTTP, comme dans l'article sur l'API REST et celui sur le Service Container.

Pourquoi chercher une autre approche

Le problème n'est pas Laravel en soi. Laravel est excellent pour prototyper vite. Mais quand le projet grossit, le pattern MVC classique montre ses limites. Un UserController qui gère l'inscription, la validation, l'envoi d'e-mail et la création du profil dans la même méthode est difficile à tester unitairement sans mocker une grande partie du framework.

Un autre symptôme classique : une règle métier dupliquée entre un controller et un observer Eloquent, avec des comportements légèrement différents. Ce genre de bug est long à tracer, et il n'apparaît pas quand la logique métier vit à un seul endroit.

Le principe en résumé (sans le jargon)

L'idée centrale : les règles métier ne doivent dépendre de rien d'externe. Pas de Laravel, pas d'Eloquent, pas de base de données. Ces détails techniques sont relégués dans des couches extérieures, qui peuvent être remplacées sans toucher au cœur de l'application. C'est la règle de dépendance : les dépendances de code pointent toujours vers l'intérieur.

En pratique, on obtient quatre niveaux :

  • Domain : le cœur. Les entités, les règles métier, les interfaces des repositories. Ce dossier ne contient aucun use Illuminate\.... Aucune dépendance au framework.
  • Application : les cas d'utilisation concrets. "Créer un utilisateur", "Archiver un article". Chaque action métier a sa propre classe avec une entrée et une sortie bien définies.
  • Infrastructure : là où on branche les outils réels : Eloquent pour la persistance, un service SMTP pour les e-mails, une API externe. C'est la couche qu'on peut remplacer.
  • Présentation : les controllers Laravel, les Form Requests, les routes, les réponses HTTP. Juste de la plomberie entre le web et l'application. Dans un projet Laravel, cette couche est simplement app/Http, inutile de la déplacer.

Une précision de vocabulaire : cette organisation en couches avec des interfaces (ports) dans le Domain et des implémentations (adapters) dans l'Infrastructure relève autant de l'architecture hexagonale et du DDD en couches que de la Clean Architecture au sens strict. Les trois partagent la même règle de dépendance. La version stricte de Martin distingue en plus les "interface adapters" (presenters, gateways) comme un cercle à part. Ici, on garde quatre dossiers, ce qui reste un bon compromis pour un projet Laravel.

La structure proposée

app/
├── Domain/
│   ├── Entities/
│   │   └── User.php
│   ├── Exceptions/
│   │   └── UserAlreadyExistsException.php
│   ├── ValueObjects/
│   │   └── Email.php
│   └── Repositories/
│       └── UserRepositoryInterface.php
├── Application/
│   └── UseCases/
│       └── CreateUser/
│           ├── CreateUserUseCase.php
│           ├── CreateUserInput.php
│           └── CreateUserOutput.php
├── Infrastructure/
│   └── Persistence/
│       └── EloquentUserRepository.php
├── Models/
│   └── User.php                       (modèle Eloquent, côté Infrastructure)
└── Http/                              (Présentation)
    ├── Controllers/
    │   └── UserController.php
    └── Requests/
        └── StoreUserRequest.php

Cette arborescence (placée sous app/, ce qui correspond aux namespaces App\... déjà gérés par l'autoload PSR-4 de Laravel) peut sembler excessive pour du PHP. Son intérêt est que chaque fichier a sa place : pour trouver la logique de création d'un utilisateur, on va dans Application/UseCases/CreateUser/, pas dans un controller de 300 lignes.

Il n'y a volontairement pas de UserService fourre-tout à côté des Use Cases : chaque action métier est une classe à part entière. Garder un service générique à côté recréerait le problème qu'on cherche à éliminer.

L'Entity : un objet métier sans Eloquent

Voici l'entité User, qui porte le même nom que le modèle Eloquent App\Models\User mais vit dans un autre espace de noms. Le point important : elle n'étend pas Model d'Eloquent. C'est un POPO (Plain Old PHP Object) :

namespace App\Domain\Entities;

use App\Domain\ValueObjects\Email;

final class User
{
    private function __construct(
        private ?int $id,
        private string $name,
        private Email $email,
        private \DateTimeImmutable $createdAt
    ) {}

    // Nouvel utilisateur : pas encore d'identifiant, il sera attribué à l'enregistrement
    public static function create(string $name, Email $email): self
    {
        self::assertValidName($name);

        return new self(
            id: null,
            name: $name,
            email: $email,
            createdAt: new \DateTimeImmutable()
        );
    }

    // Reconstitution depuis la persistance : l'identifiant existe déjà
    public static function reconstitute(
        int $id,
        string $name,
        Email $email,
        \DateTimeImmutable $createdAt
    ): self {
        return new self($id, $name, $email, $createdAt);
    }

    public function changeName(string $name): void
    {
        self::assertValidName($name);
        $this->name = $name;
    }

    // Règle unique, appliquée à la création comme à la modification
    private static function assertValidName(string $name): void
    {
        if (trim($name) === '') {
            throw new \InvalidArgumentException('Name cannot be empty');
        }
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): string
    {
        return $this->name;
    }

    public function getEmail(): Email
    {
        return $this->email;
    }

    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }
}

Deux choses à noter. D'abord, le constructeur est private : on passe par des named constructors (create() pour un nouvel utilisateur, reconstitute() pour un utilisateur déjà persisté) qui rendent l'intention explicite. Ensuite, la validation est dans l'entité elle-même. Si le nom est vide, c'est l'entité qui refuse, pas un FormRequest Laravel. La règle métier reste au même endroit, quel que soit le point d'entrée (API, commande artisan, job en queue).

Détail de validation : on utilise trim($name) === '' plutôt que empty($name), car empty("0") vaut true et refuserait à tort un nom valide, alors que empty(" ") vaut false et laisserait passer un nom fait d'espaces. Les deux points d'entrée, create() et changeName(), passent par la même méthode assertValidName(), donc la règle ne peut pas diverger.

Les Value Objects

Le Email ci-dessous est un Value Object. Il est courant de passer des string partout et de valider dans le controller, ce qui conduit à revalider le même format d'e-mail à plusieurs endroits du code.

namespace App\Domain\ValueObjects;

final class Email
{
    private function __construct(
        private string $value
    ) {}

    public static function fromString(string $email): self
    {
        // Normalisation : "Jean@Example.COM " et "jean@example.com" désignent la même adresse
        $normalized = strtolower(trim($email));

        if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
            throw new \InvalidArgumentException('Invalid email format');
        }
        return new self($normalized);
    }

    public function value(): string
    {
        return $this->value;
    }

    public function equals(Email $other): bool
    {
        return $this->value === $other->value;
    }
}

Avec ce Value Object, la validation du format est faite en un seul endroit. Si un objet Email existe dans le code, il est valide par construction. La méthode equals() évite les comparaisons de strings hasardeuses. Les adresses mal formatées n'arrivent plus jusqu'en base, et la normalisation en minuscules évite les doublons qui ne diffèrent que par la casse.

Le Use Case : une action, une classe

C'est sans doute le changement qui pèse le plus sur la façon de coder. Un UserService fourre-tout avec create(), update(), delete(), sendVerification(), resetPassword() oblige à lire chaque méthode en entier pour savoir ce qu'elle fait. Avec un Use Case par action, le nom de la classe dit tout.

namespace App\Application\UseCases\CreateUser;

use App\Domain\Entities\User;
use App\Domain\Exceptions\UserAlreadyExistsException;
use App\Domain\Repositories\UserRepositoryInterface;
use App\Domain\ValueObjects\Email;

final class CreateUserUseCase
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {}

    public function execute(CreateUserInput $input): CreateUserOutput
    {
        $email = Email::fromString($input->email);

        if ($this->userRepository->existsByEmail($email)) {
            throw new UserAlreadyExistsException();
        }

        $user = User::create($input->name, $email);

        // save() retourne l'entité persistée, avec son identifiant attribué
        $savedUser = $this->userRepository->save($user);

        return new CreateUserOutput(
            id: $savedUser->getId(),
            name: $savedUser->getName(),
            email: $savedUser->getEmail()->value()
        );
    }
}

Côté flux, User::create() produit une entité sans identifiant (id vaut null), et c'est la persistance qui attribue l'id. C'est pourquoi save() retourne l'entité enregistrée plutôt que void. Avec un save(): void, le Use Case lirait un getId() toujours null et la réponse contiendrait un identifiant vide.

Les deux objets d'entrée et de sortie sont de simples conteneurs de données :

namespace App\Application\UseCases\CreateUser;

final class CreateUserInput
{
    public function __construct(
        public readonly string $name,
        public readonly string $email,
    ) {}
}

final class CreateUserOutput
{
    public function __construct(
        public readonly int $id,
        public readonly string $name,
        public readonly string $email,
    ) {}
}

(Un fichier par classe en pratique ; ils sont regroupés ici pour la lecture.) L'exception est une classe du Domain, sans dépendance au framework :

namespace App\Domain\Exceptions;

class UserAlreadyExistsException extends \DomainException
{
}

Chaque Use Case a son propre objet d'entrée et de sortie, ici CreateUserInput et CreateUserOutput. Ces noms évitent la confusion avec StoreUserRequest, qui désigne dans Laravel le Form Request de validation HTTP, et avec les classes Request et Response du framework. Ça paraît verbeux au début, et ça l'est. L'avantage est concret : on peut écrire un test unitaire pour CreateUserUseCase avec un faux repository en mémoire, sans aucune base de données.

Le contrat du Repository

L'interface vit dans le Domain. L'implémentation avec Eloquent vit dans Infrastructure. Ces deux fichiers ne se connaissent pas directement : c'est le container d'injection de dépendances de Laravel qui fait le lien, avec un bind() dans un service provider (montré plus bas ; le mécanisme est détaillé dans l'article sur le Service Container).

namespace App\Domain\Repositories;

use App\Domain\Entities\User;
use App\Domain\ValueObjects\Email;

interface UserRepositoryInterface
{
    // Retourne l'entité persistée, avec son identifiant
    public function save(User $user): User;

    public function findById(int $id): ?User;

    public function findByEmail(Email $email): ?User;

    public function existsByEmail(Email $email): bool;
}

Et l'implémentation Eloquent, qui fait le mapping entre le model et l'entité :

namespace App\Infrastructure\Persistence;

use App\Domain\Entities\User;
use App\Domain\Exceptions\UserAlreadyExistsException;
use App\Domain\Repositories\UserRepositoryInterface;
use App\Domain\ValueObjects\Email;
use App\Models\User as UserModel;
use Illuminate\Database\UniqueConstraintViolationException;

final class EloquentUserRepository implements UserRepositoryInterface
{
    public function save(User $user): User
    {
        if ($user->getId() === null) {
            // Nouvelle entité : on persiste aussi created_at, qui vient du Domain
            $model = new UserModel();
            $model->forceFill([
                'name' => $user->getName(),
                'email' => $user->getEmail()->value(),
                'created_at' => $user->getCreatedAt(),
                'updated_at' => $user->getCreatedAt(),
            ]);
        } else {
            $model = UserModel::query()->findOrFail($user->getId());
            $model->fill([
                'name' => $user->getName(),
                'email' => $user->getEmail()->value(),
            ]);
        }

        try {
            $model->save();
        } catch (UniqueConstraintViolationException) {
            throw new UserAlreadyExistsException();
        }

        return $this->toEntity($model);
    }

    public function existsByEmail(Email $email): bool
    {
        return UserModel::query()->where('email', $email->value())->exists();
    }

    // findById() et findByEmail() suivent le même principe

    private function toEntity(UserModel $model): User
    {
        return User::reconstitute(
            id: $model->id,
            name: $model->name,
            email: Email::fromString($model->email),
            createdAt: $model->created_at->toDateTimeImmutable()
        );
    }
}

Pour simplifier, save() omet le mot de passe, alors que c'est une colonne obligatoire de la table users standard : une version complète devrait le fournir. L'essai a été fait sur une table accounts sans cette contrainte, avec une colonne email unique.

Le lien entre l'interface et son implémentation se déclare dans un service provider (à enregistrer dans config/app.php, ou dans bootstrap/providers.php sous Laravel 11 et plus) :

namespace App\Providers;

use App\Domain\Repositories\UserRepositoryInterface;
use App\Infrastructure\Persistence\EloquentUserRepository;
use Illuminate\Support\ServiceProvider;

class DomainServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->bind(UserRepositoryInterface::class, EloquentUserRepository::class);
    }
}

En production, $this->userRepository est un EloquentUserRepository. En test, c'est un InMemoryUserRepository de quelques dizaines de lignes, qui garde les entités dans un tableau (dans tests/Support, par exemple). Plus besoin de RefreshDatabase pour tester la logique métier : les tests unitaires s'exécutent sans base de données, donc très vite.

namespace Tests\Unit\Application;

use App\Application\UseCases\CreateUser\CreateUserInput;
use App\Application\UseCases\CreateUser\CreateUserUseCase;
use App\Domain\Exceptions\UserAlreadyExistsException;
use PHPUnit\Framework\TestCase;
use Tests\Support\InMemoryUserRepository;

class CreateUserUseCaseTest extends TestCase
{
    public function test_creates_a_user_with_a_normalized_email(): void
    {
        $useCase = new CreateUserUseCase(new InMemoryUserRepository());

        $output = $useCase->execute(new CreateUserInput('Ada', ' Ada@Example.COM '));

        $this->assertSame('ada@example.com', $output->email);
    }

    public function test_refuses_a_duplicate_email(): void
    {
        $useCase = new CreateUserUseCase(new InMemoryUserRepository());
        $useCase->execute(new CreateUserInput('Ada', 'ada@example.com'));

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

        $useCase->execute(new CreateUserInput('Ada bis', 'ada@example.com'));
    }
}

Ces deux comportements (normalisation de l'e-mail et refus d'un doublon) ont été exécutés dans l'essai, avec le dépôt en mémoire comme avec le dépôt Eloquent.

Le contrôleur : de la plomberie

Côté présentation, le contrôleur valide la requête HTTP avec un Form Request, construit l'entrée du Use Case, l'exécute et traduit le résultat (ou l'exception du Domain) en réponse HTTP :

namespace App\Http\Controllers;

use App\Application\UseCases\CreateUser\CreateUserInput;
use App\Application\UseCases\CreateUser\CreateUserUseCase;
use App\Domain\Exceptions\UserAlreadyExistsException;
use App\Http\Requests\StoreUserRequest;
use Illuminate\Http\JsonResponse;

class UserController extends Controller
{
    public function store(StoreUserRequest $request, CreateUserUseCase $createUser): JsonResponse
    {
        try {
            $output = $createUser->execute(
                new CreateUserInput($request->validated('name'), $request->validated('email'))
            );
        } catch (UserAlreadyExistsException) {
            return response()->json(['message' => 'Un compte existe déjà avec cet e-mail.'], 409);
        }

        return response()->json(['id' => $output->id, 'name' => $output->name, 'email' => $output->email], 201);
    }
}

CreateUserUseCase est injecté par le container, qui résout lui-même son UserRepositoryInterface grâce au bind(). Le Form Request StoreUserRequest porte les règles de format (required, email, longueur maximale) ; les règles métier restent dans le Domain.

Un dernier point sur existsByEmail() : vérifier l'existence puis enregistrer n'est pas atomique. Deux requêtes simultanées avec le même e-mail peuvent passer toutes les deux la vérification, puis tenter chacune d'insérer. Le contrôle applicatif améliore le message d'erreur, mais c'est la contrainte unique en base ($table->unique('email') dans la migration) qui garantit l'unicité. C'est pourquoi save() traduit la violation de cette contrainte en UserAlreadyExistsException (UniqueConstraintViolationException est présente dans Laravel 10.50 et dans les versions 11 et 12 testées ; elle est absente d'un Laravel 10.15, où l'on intercepterait une QueryException).

Les contreparties

Voici les coûts à connaître avant de se lancer :

Le mapping entité et modèle Eloquent est pénible. Il faut écrire des hydratateurs pour convertir un model Eloquent en entité du Domain et vice-versa. C'est du code répétitif, et sur un projet avec une quinzaine d'entités, ça représente un volume non négligeable.

Toute l'équipe doit comprendre l'approche. Si quelqu'un met de la logique métier dans un controller "pour aller vite", l'architecture perd son intérêt. L'investissement en documentation et en code review est réel.

C'est excessif pour un CRUD simple. Pour un site vitrine ou un petit back-office, un MVC classique avec quelques Services fait largement le travail. Appliquer la Clean Architecture à un module d'administration basique, c'est écrire trois fichiers là où un seul suffirait.

Pour un domaine métier complexe, c'est rentable. Un module de gestion des commandes, avec des règles de remboursement, des statuts multiples et des intégrations de paiement, bénéficie nettement de la séparation : les règles sont testables isolément et le code d'infrastructure peut évoluer sans les toucher.

Quand l'adopter

La Clean Architecture n'est pas une recette magique. C'est un outil qui a son contexte d'utilisation. Sur un projet Laravel qui dépasse le stade du prototype, avec un nombre significatif d'entités métier aux règles complexes, la séparation en couches rend le code plus prévisible.

Le bénéfice le plus tangible est la facilité de test. Quand les Use Cases n'ont aucune dépendance au framework, on peut écrire des tests qui vérifient le comportement métier en quelques lignes, sans setup complexe.

Pour refactorer un projet existant, une approche raisonnable est de commencer par un seul module, le plus complexe en termes de logique métier, et de laisser le reste en l'état. On voit ainsi si l'investissement vaut le coup pour son contexte avant de réécrire toute la codebase.

Partager cet article

Envie de ne rien rater ?

Recevez les nouveaux articles directement dans votre boîte mail. Pas de spam, uniquement du contenu technique.

Commentaires (0)

Laisser un commentaire

0 / 2000

Aucun commentaire pour le moment. Soyez le premier à réagir !

Lecture

Articles similaires

Envie d'en discuter ?

Une opportunité d'alternance, un projet ou une question technique : écrivez-moi, je réponds généralement sous 24 à 48 heures.