ahmed-bhs/hexagonal-maker-bundle
layout: default
Ce guide explique comment implémenter correctement le pattern Repository dans une architecture hexagonale, et pourquoi il ne faut pas utiliser directement l'EntityManager.
{: .no_toc .text-delta }
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.
GalinetteRepository ne contient que des objets Galinette// ❌ 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 :
// ✅ 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);
}
}
InMemoryUserRepository pour les testsinterface 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 :
<?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 |
Définir l'interface dans le domaine
Implémenter dans l'infrastructure
Retourner UNIQUEMENT des objets du domaine
Cacher les exceptions tierces (traduire en exceptions du domaine)
Générer les identités (méthode nextIdentity())
Flusher dans le repository (transaction atomique par agrégat)
❌ Retourner des tableaux de scalaires
❌ Retourner des QueryBuilder
❌ Exposer l'EntityManager
❌ Mélanger lecture et écriture complexe
❌ Créer des dizaines de méthodes findBy*
❌ 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-...
How can I help you explore Laravel packages today?