Weave Code
Code Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Hexagonal Maker Bundle Laravel Package

ahmed-bhs/hexagonal-maker-bundle

View on GitHub
Deep Wiki
Context7

layout: default

Le Pattern Repository en Architecture Hexagonale

Ce guide explique comment implémenter correctement le pattern Repository dans une architecture hexagonale, et pourquoi il ne faut pas utiliser directement l'EntityManager.


Table des matières

{: .no_toc .text-delta }

  1. TOC {:toc}

Qu'est-ce que le Pattern Repository ?

Un Repository se comporte comme une collection d'objets du domaine. Il agit comme un intermédiaire entre la couche domaine et la couche de persistance.

Caractéristiques principales

  1. Collection typée : Un GalinetteRepository ne contient que des objets Galinette
  2. Unicité : Impossible d'ajouter deux fois le même objet (identité unique)
  3. Abstraction de la persistance : Le développeur manipule une collection, le repository gère la persistance
  4. Contrat dans le domaine : L'interface est définie dans le domaine, l'implémentation dans l'infrastructure

Pourquoi NE PAS utiliser directement l'EntityManager ?

MAUVAISE PRATIQUE: Problèmes avec l'EntityManager direct

// ❌ MAUVAISE PRATIQUE
class CreateUserHandler
{
    public function __construct(
        private EntityManagerInterface $entityManager
    ) {}

    public function __invoke(CreateUserCommand $command): void
    {
        $user = new User($command->email, $command->name);

        $this->entityManager->persist($user);
        $this->entityManager->flush();
    }
}

Problèmes :

  1. Couplage fort à Doctrine : Votre domaine dépend de Doctrine
  2. Difficile à tester : Vous devez mocker l'EntityManager
  3. Violation du principe d'inversion de dépendance : Le domaine dépend de l'infrastructure
  4. Pas de point central de persistance : La logique de persistance est éparpillée
  5. Migration difficile : Changer de solution de persistance = refactoring massif

BONNE PRATIQUE: Avantages du Repository

// ✅ BONNE PRATIQUE
class CreateUserHandler
{
    public function __construct(
        private UserRepositoryInterface $userRepository
    ) {}

    public function __invoke(CreateUserCommand $command): void
    {
        $userId = $this->userRepository->nextIdentity();
        $user = User::create($userId, $command->email, $command->name);

        $this->userRepository->add($user);
    }
}
  1. Indépendance technologique : Le domaine ne connaît pas Doctrine
  2. Testabilité : Facile de créer un InMemoryUserRepository pour les tests
  3. Principe d'inversion de dépendance : L'infrastructure dépend du domaine
  4. Point central de persistance : Toute la logique est dans le repository
interface RepositoryInterface
{
    /**
     * Génère la prochaine identité disponible
     */
    public function nextIdentity(): EntityId;

    /**
     * Ajoute une entité à la collection
     */
    public function add(Entity $entity): void;

    /**
     * Récupère une entité par son identité
     * [@throws](https://github.com/throws) EntityNotFoundException
     */
    public function get(EntityId $id): Entity;

    /**
     * Supprime une entité (optionnel selon le métier)
     */
    public function remove(EntityId $id): void;
}
<?php
// src/Hunting/Galinette/Domain/Repository/GalinetteRepositoryInterface.php

namespace App\Hunting\Galinette\Domain\Repository;

use App\Hunting\Galinette\Domain\Model\Galinette;
use App\Hunting\Galinette\Domain\ValueObject\GalinetteId;
use App\Hunting\Galinette\Domain\Exception\GalinetteNotFoundException;

interface GalinetteRepositoryInterface
{
    /**
     * Génère un nouvel identifiant unique pour une Galinette
     */
    public function nextIdentity(): GalinetteId;

    /**
     * Ajoute une Galinette à la collection
     *
     * [@throws](https://github.com/throws) PersistenceException Si la persistance échoue
     */
    public function add(Galinette $galinette): void;

    /**
     * Récupère une Galinette par son identité
     *
     * [@throws](https://github.com/throws) GalinetteNotFoundException Si la Galinette n'existe pas
     * [@throws](https://github.com/throws) PersistenceException Si la récupération échoue
     */
    public function get(GalinetteId $id): Galinette;

    /**
     * Note : remove() n'est pas nécessaire car une Galinette
     * ne se supprime pas, elle va au paradis (goToHeaven())
     */
}
<?php
// src/Hunting/Galinette/Infrastructure/Persistence/Doctrine/DoctrineGalinetteRepository.php

namespace App\Hunting\Galinette\Infrastructure\Persistence\Doctrine;

use App\Hunting\Galinette\Domain\Model\Galinette;
use App\Hunting\Galinette\Domain\Repository\GalinetteRepositoryInterface;
use App\Hunting\Galinette\Domain\ValueObject\GalinetteId;
use App\Hunting\Galinette\Domain\Exception\GalinetteNotFoundException;
use App\Hunting\Galinette\Domain\Exception\PersistenceException;
use Doctrine\ORM\EntityManagerInterface;
use Ramsey\Uuid\Uuid;

final class DoctrineGalinetteRepository implements GalinetteRepositoryInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager
    ) {}

    public function nextIdentity(): GalinetteId
    {
        try {
            return GalinetteId::fromString(Uuid::uuid4()->toString());
        } catch (\Exception $e) {
            throw new PersistenceException(
                'Failed to generate identity',
                0,
                $e
            );
        }
    }

    public function add(Galinette $galinette): void
    {
        try {
            $this->entityManager->persist($galinette);
            $this->entityManager->flush();
        } catch (\Exception $e) {
            throw new PersistenceException(
                sprintf('Failed to persist Galinette with id %s', $galinette->getId()),
                0,
                $e
            );
        }
    }

    public function get(GalinetteId $id): Galinette
    {
        try {
            $galinette = $this->entityManager->find(
                Galinette::class,
                $id->toString()
            );

            if (null === $galinette) {
                throw new GalinetteNotFoundException(
                    sprintf('Galinette with id %s not found', $id->toString())
                );
            }

            return $galinette;
        } catch (GalinetteNotFoundException $e) {
            throw $e;
        } catch (\Exception $e) {
            throw new PersistenceException(
                sprintf('Failed to retrieve Galinette with id %s', $id->toString()),
                0,
                $e
            );
        }
    }
}
<?php
// src/Repository/UserRepository.php

namespace App\Repository;

use App\Entity\User;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class UserRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, User::class);
    }

    // ❌ Retourne des tableaux
    public function findActiveUsers(): array
    {
        return $this->createQueryBuilder('u')
            ->where('u.active = :active')
            ->setParameter('active', true)
            ->getQuery()
            ->getResult();
    }

    // ❌ Retourne des scalaires
    public function countActiveUsers(): int
    {
        return $this->createQueryBuilder('u')
            ->select('COUNT(u.id)')
            ->where('u.active = :active')
            ->setParameter('active', true)
            ->getQuery()
            ->getSingleScalarResult();
    }

    // ❌ Retourne un QueryBuilder
    public function getActiveUsersQueryBuilder(): QueryBuilder
    {
        return $this->createQueryBuilder('u')
            ->where('u.active = :active')
            ->setParameter('active', true);
    }

    // ❌ Méthode qui expose le détail de la persistance
    public function save(User $user, bool $flush = false): void
    {
        $this->getEntityManager()->persist($user);

        if ($flush) {
            $this->getEntityManager()->flush();
        }
    }
}

Problèmes :

  • Mélange lecture et écriture
  • Retourne différents types (objets, tableaux, scalaires, QueryBuilder)
  • Ne respecte pas le contrat d'un vrai Repository
  • Devient un "God Object" avec des dizaines de méthodes
<?php
// src/User/Domain/Repository/UserRepositoryInterface.php

namespace App\User\Domain\Repository;

use App\User\Domain\Model\User;
use App\User\Domain\ValueObject\UserId;
use App\User\Domain\Exception\UserNotFoundException;

interface UserRepositoryInterface
{
    public function nextIdentity(): UserId;

    public function add(User $user): void;

    /**
     * [@throws](https://github.com/throws) UserNotFoundException
     */
    public function get(UserId $id): User;

    /**
     * [@throws](https://github.com/throws) UserNotFoundException
     */
    public function getByEmail(string $email): User;

    /**
     * Finder additionnel : retourne uniquement des objets User
     * [@return](https://github.com/return) User[]
     */
    public function findActive(): array;
}
<?php
// src/User/Infrastructure/Persistence/Doctrine/DoctrineUserRepository.php

namespace App\User\Infrastructure\Persistence\Doctrine;

use App\User\Domain\Model\User;
use App\User\Domain\Repository\UserRepositoryInterface;
use App\User\Domain\ValueObject\UserId;
use App\User\Domain\Exception\UserNotFoundException;
use App\User\Domain\Exception\PersistenceException;
use Doctrine\ORM\EntityManagerInterface;
use Ramsey\Uuid\Uuid;

final class DoctrineUserRepository implements UserRepositoryInterface
{
    public function __construct(
        private EntityManagerInterface $entityManager
    ) {}

    public function nextIdentity(): UserId
    {
        try {
            return UserId::fromString(Uuid::uuid4()->toString());
        } catch (\Exception $e) {
            throw new PersistenceException('Failed to generate identity', 0, $e);
        }
    }

    public function add(User $user): void
    {
        try {
            $this->entityManager->persist($user);
            $this->entityManager->flush();
        } catch (\Exception $e) {
            throw new PersistenceException('Failed to persist User', 0, $e);
        }
    }

    public function get(UserId $id): User
    {
        try {
            $user = $this->entityManager->find(User::class, $id->toString());

            if (null === $user) {
                throw new UserNotFoundException(
                    sprintf('User with id %s not found', $id->toString())
                );
            }

            return $user;
        } catch (UserNotFoundException $e) {
            throw $e;
        } catch (\Exception $e) {
            throw new PersistenceException('Failed to retrieve User', 0, $e);
        }
    }

    public function getByEmail(string $email): User
    {
        try {
            $dql = 'SELECT u FROM ' . User::class . ' u WHERE u.email = :email';
            $user = $this->entityManager
                ->createQuery($dql)
                ->setParameter('email', $email)
                ->getOneOrNullResult();

            if (null === $user) {
                throw new UserNotFoundException(
                    sprintf('User with email %s not found', $email)
                );
            }

            return $user;
        } catch (UserNotFoundException $e) {
            throw $e;
        } catch (\Exception $e) {
            throw new PersistenceException('Failed to retrieve User by email', 0, $e);
        }
    }

    /**
     * Finder additionnel : retourne UNIQUEMENT des objets User
     */
    public function findActive(): array
    {
        try {
            $dql = 'SELECT u FROM ' . User::class . ' u WHERE u.active = :active';
            return $this->entityManager
                ->createQuery($dql)
                ->setParameter('active', true)
                ->getResult();
        } catch (\Exception $e) {
            throw new PersistenceException('Failed to find active users', 0, $e);
        }
    }
}
<?php
// src/User/Infrastructure/Query/GetActiveUsersQuery.php

namespace App\User\Infrastructure\Query;

use Doctrine\DBAL\Connection;

/**
 * Query function pour la lecture/reporting
 * Ne fait PAS partie du Repository
 */
final class GetActiveUsersQuery
{
    public function __construct(
        private Connection $connection
    ) {}

    /**
     * [@return](https://github.com/return) ActiveUserDTO[]
     */
    public function __invoke(): array
    {
        $sql = <<<SQL
            SELECT
                u.id,
                u.email,
                u.name,
                u.created_at,
                COUNT(o.id) as order_count
            FROM user u
            LEFT JOIN `order` o ON o.user_id = u.id
            WHERE u.active = 1
            GROUP BY u.id
            ORDER BY u.created_at DESC
        SQL;

        $rows = $this->connection->fetchAllAssociative($sql);

        return array_map(
            fn(array $row) => new ActiveUserDTO(
                $row['id'],
                $row['email'],
                $row['name'],
                new \DateTimeImmutable($row['created_at']),
                (int) $row['order_count']
            ),
            $rows
        );
    }
}
<?php
// src/User/Infrastructure/Query/ActiveUserDTO.php

namespace App\User\Infrastructure\Query;

/**
 * DTO pour la lecture
 * Facilement normalisable pour API REST ou templates Twig
 */
final readonly class ActiveUserDTO
{
    public function __construct(
        public string $id,
        public string $email,
        public string $name,
        public \DateTimeImmutable $createdAt,
        public int $orderCount
    ) {}
}
<?php
// src/User/Application/Command/CreateUserHandler.php

namespace App\User\Application\Command;

use App\User\Domain\Model\User;
use App\User\Domain\Repository\UserRepositoryInterface;

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

    public function __invoke(CreateUserCommand $command): void
    {
        // Génération de l'identité (pas de dépendance à l'auto-increment MySQL)
        $userId = $this->userRepository->nextIdentity();

        // Création de l'objet du domaine
        $user = User::create($userId, $command->email, $command->name);

        // Persistance (abstraction complète)
        $this->userRepository->add($user);
    }
}
<?php
// src/User/Presentation/Controller/ListActiveUsersController.php

namespace App\User\Presentation\Controller;

use App\User\Infrastructure\Query\GetActiveUsersQuery;
use Symfony\Component\HttpFoundation\JsonResponse;

final class ListActiveUsersController
{
    public function __construct(
        private GetActiveUsersQuery $getActiveUsersQuery
    ) {}

    public function __invoke(): JsonResponse
    {
        // Pour la lecture, on utilise une Query Function
        $activeUsers = ($this->getActiveUsersQuery)();

        return new JsonResponse($activeUsers);
    }
}
Méthode Symfony classique Solution Hexagonale Raison
findActiveUsers() retourne array findActive(): array dans Repository + GetActiveUsersQuery Séparation lecture/écriture (CQRS)
countActiveUsers() retourne int CountActiveUsersQuery uniquement Les repositories ne retournent que des objets du domaine
getActiveUsersQueryBuilder() GetActiveUsersQuery avec SQL pur Pas de QueryBuilder exposé, exploitation complète de SQL
  1. Définir l'interface dans le domaine

  2. Implémenter dans l'infrastructure

  3. Retourner UNIQUEMENT des objets du domaine

  4. Cacher les exceptions tierces (traduire en exceptions du domaine)

  5. Générer les identités (méthode nextIdentity())

  6. Flusher dans le repository (transaction atomique par agrégat)

  7. ❌ Retourner des tableaux de scalaires

  8. ❌ Retourner des QueryBuilder

  9. ❌ Exposer l'EntityManager

  10. ❌ Mélanger lecture et écriture complexe

  11. ❌ Créer des dizaines de méthodes findBy*

  12. ❌ Dépendre directement de Doctrine dans le domaine

interface UserRepositoryInterface
{
    /**
     * [@return](https://github.com/return) User[]  // Documentation seulement, aucune garantie !
     */
    public function findActive(): array;
}

// Utilisation
$users = $userRepository->findActive();
$users[] = new Product(); // ❌ Rien n'empêche ça !
// L'IDE ne peut pas autocomplete les méthodes de User
foreach ($users as $user) {
    $user->  // ❓ Aucune autocomplétion
}
interface UserRepositoryInterface
{
    public function findActive(): UserCollection; // ✅ Type garanti !
}

// Utilisation
$users = $userRepository->findActive();
$users[] = new Product(); // ❌ TypeError ou InvalidArgumentException !
// L'IDE sait que $user est un User
foreach ($users as $user) {
    $user->  // ✅ Autocomplétion parfaite
}

}

<?php
// src/User/Domain/Repository/UserRepositoryInterface.php

namespace App\User\Domain\Repository;

use Doctrine\Common\Collections\ArrayCollection; // ❌ PROBLÈME !

interface UserRepositoryInterface
{
    /**
     * ❌ Le Domain dépend de doctrine/collections (infrastructure)
     * ❌ Viole le principe d'indépendance du domaine
     * ❌ Si vous supprimez Doctrine, votre domaine est cassé
     */
    public function findActive(): ArrayCollection;
}

}


**Problèmes :**
- Le **Domain** dépend d'un package d'infrastructure (`doctrine/collections`)

- Rend le domaine difficile à tester indépendamment

```php
<?php
// src/Shared/Domain/Collection/AbstractCollection.php

namespace App\Shared\Domain\Collection;

/**
 * Collection abstraite réutilisable
 * ✅ Pur PHP, aucune dépendance externe
 * ✅ Implémente Iterator, Countable, ArrayAccess
 * ✅ À étendre pour chaque type d'entité
 *
 * [@template](https://github.com/template) T
 */
abstract class AbstractCollection implements \Iterator, \Countable, \ArrayAccess
{
    /** [@var](https://github.com/var) T[] */
    protected array $items = [];
    protected int $position = 0;

    /**
     * [@param](https://github.com/param) T[] $items
     */
    public function __construct(array $items = [])
    {
        foreach ($items as $item) {
            $this->validateType($item);
        }
        $this->items = array_values($items);
    }

    /**
     * Validation de type (implémenté par les classes enfants)
     */
    abstract protected function validateType(mixed $item): void;

    // ========================================
    // Iterator - Permet foreach()
    // ========================================

    public function current(): mixed
    {
        return $this->items[$this->position];
    }

    public function key(): int
    {
        return $this->position;
    }

    public function next(): void
    {
        ++$this->position;
    }

    public function rewind(): void
    {
        $this->position = 0;
    }

    public function valid(): bool
    {
        return isset($this->items[$this->position]);
    }

    // ========================================
    // Countable - Permet count()
    // ========================================

    public function count(): int
    {
        return count($this->items);
    }

    // ========================================
    // ArrayAccess - Permet $collection[0]
    // ========================================

    public function offsetExists(mixed $offset): bool
    {
        return isset($this->items[$offset]);
    }

    public function offsetGet(mixed $offset): mixed
    {
        return $this->items[$offset];
    }

    public function offsetSet(mixed $offset, mixed $value): void
    {
        $this->validateType($value);

        if (null === $offset) {
            $this->items[] = $value;
        } else {
            $this->items[$offset] = $value;
        }
    }

    public function offsetUnset(mixed $offset): void
    {
        unset($this->items[$offset]);
        $this-...
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
terminal42/code-quality-tools
codifyo/ts-generator-bundle
andydefer/laravel-cluster
testo/fiber
mintobit/jobqueue
a4sex/maintenance-bundle
a4sex/entity-date-update
a4sex/client-identifier
a4sex/base-utilites
a4sex/key-value-storage
a4sex/micro-status
chilldev/dependency-injection-extra
datinglibre/datinglibre-app-api
biberltd/corebundle
bricre/symfony-bundle-test
biberltd/logbundle
dominium/http-adapter-bundle
dominium/google-analytics
a4sex/auto-clean-entity
christhompsontldr/laravel-inky