← Tous les cours / DDD : démêler les relations many-to-many Cours
00 — INTRODUCTION

La relation many-to-many qui cache tout

Une relation ManyToMany semble évidente. Elle est souvent le symptôme d'un agrégat mal placé.

Les ORM adorent les relations many-to-many. C'est un raccourci séduisant : deux entités, une table de jointure, quelques lignes de mapping, et voilà — tu peux associer des Job à des Platform dans les deux sens.

Sauf qu'une relation ManyToMany cache rarement ce qu'elle a l'air de dire.

Le piège du many-to-many
Une relation many-to-many est souvent le symptôme qu'un agrégat n'est pas à sa place — qu'une responsabilité n'a pas trouvé son maître.

Prenons un exemple concret : tu dois associer des offres d'emploi aux plateformes de diffusion où elles doivent paraître. C'est une relation many-to-many ; une offre peut apparaître sur plusieurs sites, un site accueille plusieurs offres.

Doctrine te propose de bâtir ça en deux entités et une table de jonction. Ça compile. Les tests passent. Le code est lisible.

Mais… qui devrait être responsable de cette association ? L'offre d'emploi ? La plateforme ? Quelque chose d'autre ?

🔑 La vraie question : Si la plateforme devient indisponible, l'association doit-elle échouer ? Ou devrais-tu pouvoir enregistrer l'offre sans elle, et la ajouter plus tard ?

C'est là qu'intervient la pensée par agrégats du DDD. Un agrégat est une grappe d'entités qui partagent une raison de vivre — une invariante métier à préserver ensemble.

Dans ce cours, on va remodéliser cette relation step by step : d'abord voir où elle casse, puis construire une entité qui a un vrai sens métier et qui rompt la dépendance implicite.

À la fin, tu auras la réponse à cette question : ce many-to-many était-il vraiment un many-to-many ?

01 — LE MODÈLE NAÏF

Le modèle naïf : deux entités, une table de jointure

Deux entités, une table de jointure, un mapping Doctrine en cinq lignes. Ça a l'air parfait.

Commençons par modéliser le problème tel qu'on le voit naturellement. Tu as deux entités : Job, qui représente une offre d'emploi, et JobBoard, qui représente une plateforme de diffusion. Une offre peut paraître sur plusieurs plateformes, une plateforme accueille plusieurs offres. C'est une relation many-to-many au sens strict.

Voici à quoi ressemblent ces deux entités avec leur mapping Doctrine :

// src/Domain/Job.php
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ArrayCollection;

#[Entity]
class Job
{
    #[Id, GeneratedValue(strategy: 'AUTO'), Column]
    private int $id;

    #[Column(length: 255)]
    private string $title;

    #[ManyToMany(targetEntity: JobBoard::class, cascade: ['persist'])]
    #[JoinTable(name: 'job_job_board')]
    private Collection $jobBoards;

    public function __construct(string $title)
    {
        $this->title     = $title;
        $this->jobBoards = new ArrayCollection();
    }

    public function addJobBoard(JobBoard $board): void
    {
        if (!$this->jobBoards->contains($board)) {
            $this->jobBoards->add($board);
        }
    }
}

Le cascade: ['persist'] permet de persister les JobBoard associés en même temps que le Job — pas besoin d'appeler persist() séparément sur chaque plateforme.

// src/Domain/JobBoard.php
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\Collection;

#[Entity]
class JobBoard
{
    #[Id, GeneratedValue(strategy: 'AUTO'), Column]
    private int $id;

    #[Column(length: 255)]
    private string $name;

    #[ManyToMany(targetEntity: Job::class, mappedBy: 'jobBoards')]
    private Collection $jobs;

    public function __construct(string $name)
    {
        $this->name = $name;
        $this->jobs = new ArrayCollection();
    }
}

Du côté de Job, l'attribut #[ManyToMany] est le côté propriétaire (owning side) : c'est lui qui pilote la table de jointure. Du côté de JobBoard, mappedBy: 'jobBoards' indique que c'est un côté miroir — il lit l'état, il ne l'écrit pas.

La table intermédiaire job_job_board ne contient que deux colonnes : job_id et job_board_id. Doctrine la crée automatiquement à partir du #[JoinTable] et en gère le contenu de façon transparente.

Job
job_job_board
JobBoard
Relation ManyToMany en Doctrine
La table de jointure ne correspond à aucune entité PHP : Doctrine la gère entièrement en coulisses. Pour associer un Job à un JobBoard, il suffit d'appeler $job->addJobBoard($board). À la prochaine synchronisation (flush()), Doctrine insère automatiquement la ligne dans job_job_board. Côté PHP, tu manipules une collection ArrayCollection comme un tableau objet standard.

Voyons maintenant comment ce modèle s'utilise en pratique. Voici la méthode qui publie une offre sur une sélection de plateformes :

public function postJobToBoards(
    int   $jobId,
    array $boardIds,
    EntityManagerInterface $em
): void {
    // Charge l'offre d'emploi depuis la base
    $job = $em->find(Job::class, $jobId);

    // Charge chaque plateforme cible et l'associe à l'offre
    foreach ($boardIds as $boardId) {
        $board = $em->find(JobBoard::class, $boardId);
        $job->addJobBoard($board);
    }

    // Un seul flush : toutes les associations sont écrites en une transaction
    $em->flush();
}

La méthode est courte, lisible, idiomatique. Elle charge, elle associe, elle persiste. Un seul flush() à la fin pour tout valider en une seule transaction.

🔍 Une hypothèse silencieuse : ce code suppose que toutes les plateformes répondent au même moment et que l'association avec chacune d'elles est atomique. Est-ce vraiment le cas dans ton métier ?

Le code semble parfait. Pourtant, une hypothèse est enfouie dedans — une hypothèse si naturelle qu'on ne la voit pas au premier coup d'œil. Dans la section suivante, on va la faire remonter à la surface.

02 — LE PROBLÈME CACHÉ

Le problème caché : une transaction qui engloutit tout

Un flush, plusieurs plateformes. Si l'une est indisponible, tout échoue.

Reprenons notre méthode postJobToBoards(). L'entreprise vient de valider une campagne de recrutement ambitieuse : l'offre doit être publiée sur cinq plateformes — LinkedIn, Indeed, APEC, Welcome to the Jungle et une plateforme interne. Tu reçois les cinq identifiants, tu lances la méthode.

Tout se passe bien pour les quatre premières. Mais au moment où Doctrine s'apprête à insérer la ligne pour la troisième plateforme, une erreur réseau surgit : la plateforme est temporairement indisponible, la contrainte de clé étrangère ne peut pas être vérifiée, l'appel échoue.

Doctrine lève une exception. La transaction — qui englobait les cinq insertions — est immédiatement annulée en intégralité. Résultat : zéro ligne dans la table job_job_board. L'offre n'est associée à aucune plateforme, même pas aux quatre qui avaient répondu correctement.

public function postJobToBoards(
    int   $jobId,
    array $boardIds,
    EntityManagerInterface $em
): void {
    $job = $em->find(Job::class, $jobId);

    foreach ($boardIds as $boardId) {
        $board = $em->find(JobBoard::class, $boardId);
        $job->addJobBoard($board);
    }

    try {
        // ↓ toutes les associations entrent dans UNE seule transaction
        $em->flush();
    } catch (\Exception $e) {
        // La 3e plateforme est down → la transaction est rollbackée
        // Les 4 autres insertions sont annulées avec elle
        throw new PublicationFailedException(
            'Impossible de publier l\'offre : une plateforme est indisponible.',
            previous: $e
        );
    }
}

Ce comportement est parfaitement cohérent de la part de Doctrine — et de la base de données. Une transaction, c'est un contrat : tout passe ou rien ne passe. Doctrine n'a fait que respecter ce contrat.

Frontière transactionnelle
Tout ce qu'un flush() englobe forme une seule et même transaction. Si une opération échoue, la base de données annule toutes les insertions et mises à jour de ce flush(), y compris celles qui avaient réussi. C'est ce qu'on appelle l'atomicité — le A de ACID. Il n'y a pas d'état intermédiaire possible.

Autrement dit : en mettant les cinq associations dans un même flush(), on a implicitement déclaré qu'elles devaient toutes réussir ou toutes échouer ensemble. C'est un choix de modélisation — qu'on a fait sans s'en rendre compte.

🚨 Ce n'est pas un bug Doctrine. C'est un bug de modélisation — on a mis plusieurs opérations indépendantes dans une seule transaction.

Alors, est-ce que c'est vraiment ce que ton métier attend ? Si l'APEC est indisponible ce soir, est-ce que ça a du sens que l'offre ne soit pas non plus publiée sur LinkedIn, Indeed et les autres — qui, eux, fonctionnaient parfaitement ?

Pour la plupart des services RH, la réponse est non. Publier sur quatre plateformes et relancer pour la cinquième plus tard est bien préférable à ne rien publier du tout. Mais le modèle actuel ne permet pas ça. Il n'a même pas de concept pour exprimer "publication en attente" ou "publication échouée".

C'est ici que la question du modèle de données cesse d'être technique et devient une question métier. On va l'explorer dans la section suivante.

03 — DÉFAILLANCES PARTIELLES

Les défaillances partielles sont normales

Dans la vraie vie, si un article manque au marché, tu ne rentres pas les mains vides.

Avant de remodéliser quoi que ce soit, il faut se poser une question que les développeurs omettent souvent : qu'est-ce que le métier considère comme acceptable ?

Imagine que tu fasses tes courses au marché. Sur ta liste, tu as du pain, des légumes, du fromage, de la viande et du poisson. Arrivé devant l'étal du poissonnier, mauvaise surprise : il est absent ce matin. Est-ce que tu fais demi-tour et tu rentres chez toi les mains vides ? Ou est-ce que tu achètes le pain, les légumes, le fromage et la viande — et tu notes le poisson pour demain ?

Pour la quasi-totalité des gens, la réponse est évidente : tu achètes ce qui est disponible. Rentrer les mains vides parce qu'un seul article manque serait absurde.

Défaillance partielle
Une défaillance partielle, c'est quand une opération globale réussit en partie : certains sous-objectifs aboutissent, d'autres non. Le métier l'accepte explicitement, parce qu'un résultat partiel a de la valeur — il vaut mieux que rien.

Reporte cette logique sur notre problème. L'entreprise veut publier son offre d'emploi sur LinkedIn, Indeed, APEC, Welcome to the Jungle et une plateforme interne — cinq plateformes au total. Ce soir, LinkedIn est indisponible pour maintenance.

Est-ce que l'entreprise préfère ne rien publier du tout, et attendre que LinkedIn revienne ? Ou est-ce qu'elle préfère que les quatre autres publications partent immédiatement, et qu'on reprogramme LinkedIn dès qu'il est de nouveau accessible ?

Pour n'importe quel service RH, la réponse est limpide : quatre sur cinq, c'est bien mieux que zéro sur cinq. Les candidats qui cherchent sur Indeed ou l'APEC n'ont aucune raison d'attendre que LinkedIn soit en bonne santé. Chaque heure de délai, ce sont des candidats qui passent à l'offre suivante.

💡 Frontières transactionnelles : identifier ce que le métier accepte comme défaillance partielle, c'est identifier tes frontières transactionnelles. Si deux opérations peuvent réussir ou échouer de façon indépendante, elles n'ont pas à partager la même transaction.

Cette distinction change tout. Dans le modèle actuel avec une seule transaction, le code dit implicitement au métier : "soit tes cinq plateformes fonctionnent toutes au même instant, soit tu n'as rien." C'est une contrainte que le métier n'a jamais demandée — et qu'il ne veut pas.

Ce que le métier dit réellement, c'est : "chaque publication sur une plateforme est un acte indépendant. Elle réussit ou elle échoue pour ses propres raisons."

Dès lors, une question s'impose : si chaque publication est indépendante — si elle a son propre cycle de vie, son propre statut, son propre résultat — pourquoi la modéliser comme une simple ligne dans une table de jointure sans identité propre ? Une ligne anonyme dans job_job_board ne peut pas avoir un statut failed, elle ne peut pas être replanifiée, elle ne peut pas stocker la date à laquelle la publication a effectivement abouti.

🔑 Conséquence directe : si chaque posting est une opération indépendante avec son propre résultat, le ManyToMany dans une transaction unique n'a plus de sens. On a besoin d'une entité — pas d'une jointure.

C'est exactement ce que l'on va construire dans les sections suivantes : une entité JobPosting qui représente le lien entre une offre et une plateforme, avec un statut, une date, et la possibilité d'échouer sans emporter les autres avec elle.

04 — REPENSER LES AGRÉGATS

Repenser les agrégats : qui possède cette relation ?

Qui devrait être responsable d'associer un job à une plateforme ?

On vient de voir que le modèle naïf place chaque publication dans une seule transaction — et que le métier, lui, réclame des opérations indépendantes. Mais avant de coder quoi que ce soit, il faut se poser la vraie question : qui est responsable de cette relation ? Dans notre modèle, c'est Job qui "connaît" ses plateformes. Est-ce vraiment logique ?

Agrégat et racine d'agrégat
Un agrégat est une grappe d'objets qui partagent une frontière de cohérence transactionnelle. Toutes les modifications de cette grappe passent par un unique point d'entrée : la racine d'agrégat. C'est elle qui applique les invariants métier, elle qui décide si une opération est valide. De l'extérieur, on ne touche jamais directement les objets internes — on passe toujours par la racine.

Dans notre modèle actuel, Job est le côté propriétaire de la relation : c'est lui qui porte le #[ManyToMany], c'est lui qui gère la collection de JobBoard, c'est lui qu'on appelle quand on veut associer une plateforme. Job "connaît" ses plateformes — et se comporte comme si c'était de sa responsabilité d'en gérer la liste.

Mais réfléchis une seconde à ce que signifient ces phrases dans la vraie vie :

La première met l'offre au centre. La seconde met la plateforme au centre. Laquelle décrit mieux ce qui se passe réellement ? C'est Indeed qui décide d'accepter et d'afficher une offre — pas l'offre qui se projette elle-même sur Indeed. La plateforme est l'acteur ; l'offre est le contenu.

Le sens naturel de l'action
En DDD, on cherche à faire en sorte que le code ressemble au langage du métier. Si les utilisateurs disent "Indeed publie ce job", alors c'est $jobBoard->post($job) — et non $job->addJobBoard($jobBoard) — qui exprime correctement l'intention.

Ce changement de perspective n'est pas qu'une question de style. Il déplace la frontière de cohérence : au lieu que Job soit responsable de la liste de ses plateformes, c'est chaque JobBoard qui décide de publier ou non une offre. Et surtout, chaque publication devient une opération autonome — elle peut réussir ou échouer sans affecter les autres.

Modèle naïf — Job propriétaire

// Job porte la relation bidirectionnelle
#[ManyToMany(targetEntity: JobBoard::class,
             cascade: ['persist'])]
private Collection $jobBoards;

public function addJobBoard(JobBoard $board): void
{
    $this->jobBoards->add($board);
}

Et à l'usage :

// L'offre "s'ajoute" une plateforme
$job->addJobBoard($indeed);
$job->addJobBoard($apec);
$em->flush(); // une seule transaction

Modèle corrigé — JobBoard acteur

// JobBoard expose une intention métier
class JobBoard
{
    public function post(Job $job): void
    {
        // crée une publication indépendante
    }
}

Et à l'usage :

// Chaque plateforme agit pour son compte
$indeed->post($job);
$apec->post($job);
// deux opérations indépendantes possibles

La différence n'est pas que cosmétique. Dans le modèle naïf, $job->addJobBoard() dit juste : "insère une ligne dans la table de jointure". Dans le modèle corrigé, $jobBoard->post($job) dit : "Indeed prend la décision de publier cette offre." C'est une sémantique riche, qui fait écho à ce que les gens du métier décrivent.

💡 La conséquence structurelle : si chaque appel à post() est une opération indépendante sur JobBoard, il n'est plus nécessaire que la relation soit stockée dans une table de jointure anonyme. Cette opération peut créer une entité à part entière — avec son propre identifiant, son propre statut (pending, published, failed), sa propre date. Le ManyToMany disparaît naturellement : il devient une entité avec son propre cycle de vie.

C'est exactement ce qu'on va construire dans la section suivante : une entité JobPosting qui remplace la table de jointure, et qui permet à chaque publication d'exister, d'échouer ou d'être replanifiée indépendamment des autres.

05 — LE REFACTORING

Le refactoring : une entité à la place d'une jointure

Fini la table de jointure. Place à une entité qui a un sens métier.

On a posé le diagnostic. Le modèle naïf souffre d'une transaction unique qui engloutit des opérations indépendantes, et Job s'est vu attribuer une responsabilité qui ne lui appartient pas. Il est temps de construire la solution : remplacer la table de jointure anonyme par une entité qui a un nom, une identité, et un sens métier.

Cette entité, c'est JobPosting. Pense-y comme à un contrat signé entre une offre d'emploi et une plateforme de diffusion : il ne s'agit plus d'une simple ligne dans un tableau, mais d'un acte — daté, traçable, avec un statut.

Entité de relation
Quand une relation ManyToMany présente son propre cycle de vie — une date de création, un statut, des règles métier qui lui sont propres —, elle mérite d'exister en tant qu'entité à part entière. On l'appelle souvent une entité de relation ou entité d'association. Elle remplace la table de jointure par une table normale avec sa propre clé primaire, et on la manipule comme n'importe quelle autre entité du domaine.

L'entité JobPosting

Voici la nouvelle entité. Elle stocke le lien entre une offre et une plateforme, la date de publication, et laisse de la place pour un statut si ton métier en a besoin :

// src/Domain/JobPosting.php
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\ManyToOne;
use Doctrine\ORM\Mapping\JoinColumn;

#[Entity]
class JobPosting
{
    #[Id, GeneratedValue(strategy: 'AUTO'), Column]
    private int $id;

    #[ManyToOne(targetEntity: Job::class)]
    #[JoinColumn(nullable: false)]
    private Job $job;

    #[ManyToOne(targetEntity: JobBoard::class, inversedBy: 'postings')]
    #[JoinColumn(nullable: false)]
    private JobBoard $jobBoard;

    #[Column]
    private \DateTimeImmutable $postedAt;

    #[Column(length: 20, options: ['default' => 'pending'])]
    private string $status = 'pending';

    public function __construct(Job $job, JobBoard $jobBoard)
    {
        $this->job      = $job;
        $this->jobBoard = $jobBoard;
        $this->postedAt = new \DateTimeImmutable();
    }

    public function markPublished(): void
    {
        $this->status = 'published';
    }

    public function markFailed(): void
    {
        $this->status = 'failed';
    }

    public function getId(): int         { return $this->id; }
    public function getJob(): Job         { return $this->job; }
    public function getJobBoard(): JobBoard { return $this->jobBoard; }
    public function getPostedAt(): \DateTimeImmutable { return $this->postedAt; }
    public function getStatus(): string   { return $this->status; }
}

Quelques points à remarquer. Le constructeur exige un Job et un JobBoard — une JobPosting ne peut pas exister sans eux. $postedAt est initialisé à la construction : c'est le moment où la décision de publier est prise, pas le moment où elle aboutit. Et le $status est à pending par défaut — on pourra le faire passer à published ou failed selon le résultat réel de la diffusion.

💡 Statut optionnel mais précieux : si ton métier se contente d'enregistrer les publications sans suivre leur résultat, tu peux omettre $status. Mais dès que tu as besoin de relancer les publications échouées, d'afficher un tableau de bord ou de faire de la réconciliation, ce champ devient indispensable. Mieux vaut le prévoir dès le départ.

JobBoard refactorisé : la méthode post()

JobBoard perd son ManyToMany et gagne une collection de JobPosting, ainsi qu'une méthode métier qui exprime clairement l'intention — "cette plateforme publie cette offre" :

// src/Domain/JobBoard.php
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Column;
use Doctrine\ORM\Mapping\OneToMany;
use Doctrine\Common\Collections\Collection;
use Doctrine\Common\Collections\ArrayCollection;

#[Entity]
class JobBoard
{
    #[Id, GeneratedValue(strategy: 'AUTO'), Column]
    private int $id;

    #[Column(length: 255)]
    private string $name;

    #[OneToMany(targetEntity: JobPosting::class, mappedBy: 'jobBoard', cascade: ['persist'])]
    private Collection $postings;

    public function __construct(string $name)
    {
        $this->name     = $name;
        $this->postings = new ArrayCollection();
    }

    public function post(Job $job): JobPosting
    {
        $posting = new JobPosting($job, $this);
        $this->postings->add($posting);

        return $posting;
    }

    public function getId(): int            { return $this->id; }
    public function getName(): string        { return $this->name; }
    public function getPostings(): Collection { return $this->postings; }
}

Le cascade: ['persist'] sur #[OneToMany] permet d'éviter un appel explicite à $em->persist($posting) : dès qu'on appelle $board->post($job), le JobPosting nouvellement créé est automatiquement géré par Doctrine lors du prochain flush().

Job simplifié : plus aucune référence à JobBoard

C'est ici que le refactoring révèle tout son intérêt. L'entité Job n'avait aucune raison de "connaître" les plateformes — c'était une dépendance artificielle imposée par le modèle ManyToMany. Elle disparaît complètement :

// src/Domain/Job.php — version simplifiée
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\Entity;
use Doctrine\ORM\Mapping\Id;
use Doctrine\ORM\Mapping\GeneratedValue;
use Doctrine\ORM\Mapping\Column;

#[Entity]
class Job
{
    #[Id, GeneratedValue(strategy: 'AUTO'), Column]
    private int $id;

    #[Column(length: 255)]
    private string $title;

    public function __construct(string $title)
    {
        $this->title = $title;
    }

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

Plus de Collection, plus de #[ManyToMany], plus de addJobBoard(). La relation est désormais unidirectionnelle : JobPosting connaît son Job, mais Job ignore tout des plateformes qui le diffusent. C'est le modèle le plus simple possible pour représenter ce que Job est vraiment — une offre d'emploi, rien de plus.

JobBoard
→ post()
JobPosting
Job

La nouvelle méthode applicative : une transaction par plateforme

C'est ici que le modèle révèle toute sa valeur. On peut maintenant boucler sur les plateformes cibles et traiter chacune dans sa propre transaction. L'échec d'une ne pollue pas les autres. La méthode injecte ManagerRegistry $managerRegistry en plus de l'EntityManager, pour pouvoir le réinitialiser proprement en cas de fermeture :

public function postJobToBoards(
    int   $jobId,
    array $boardIds,
    EntityManagerInterface $em,
    ManagerRegistry        $managerRegistry
): void {
    $job    = $em->find(Job::class, $jobId);
    $errors = [];

    foreach ($boardIds as $boardId) {
        $board = $em->find(JobBoard::class, $boardId);

        try {
            // La plateforme crée le JobPosting et l'ajoute à sa collection
            $posting = $board->post($job);

            // On marque la publication réussie avant le flush :
            // un seul commit atomique, pas d'état intermédiaire en base
            $posting->markPublished();
            $em->flush();

        } catch (\Exception $e) {
            // Cette plateforme a échoué : on le note, on continue
            $errors[] = $board->getName() . ' : ' . $e->getMessage();

            // Réinitialiser l'EM fermé via ManagerRegistry (Symfony)
            if (!$em->isOpen()) {
                $em = $managerRegistry->resetManager();
            }
        }
    }

    if (!empty($errors)) {
        // On logge les erreurs sans tout faire échouer
        $this->logger->warning('Certaines publications ont échoué', [
            'job_id' => $jobId,
            'errors' => $errors,
        ]);
    }
}

Chaque itération est désormais autonome. Si LinkedIn est indisponible à la troisième itération, les deux premières publications sont déjà commitées — elles ne seront pas annulées. L'erreur est collectée, loggée, et la boucle continue sur la plateforme suivante.

💡 L'EntityManager après une exception : quand une exception survient au milieu d'une opération Doctrine, l'EntityManager peut passer dans un état fermé (isOpen() === false). Dans ce cas, il faut le réinitialiser via ManagerRegistry::resetManager() — c'est l'API Symfony prévue pour ça. resetManager() recrée l'EntityManager et le réenregistre dans le conteneur, ce qui garantit que les services qui y font référence récupèrent bien la nouvelle instance.
🔑 Le refactoring en résumé : on a transformé une table de jointure anonyme en une entité JobPosting avec sa propre identité. On a déplacé la responsabilité de créer cette association vers JobBoard, qui est l'acteur naturel. Et on a rendu Job agnostique des plateformes qui le diffusent — une entité plus cohérente avec ce qu'elle représente.

Cette restructuration rend possible tout ce que le modèle naïf interdisait : replanifier les publications échouées, afficher l'état de chaque diffusion, comparer les plateformes par performance, ou simplement comprendre pourquoi une offre n'est pas parue quelque part. La table de jointure ne pouvait rien de tout ça — l'entité JobPosting, si.

06 — SYNTHÈSE

Synthèse : quand suspecter un faux many-to-many ?

Un pattern, des compromis, et une question à se poser à chaque ManyToMany.

Tu as suivi l'évolution complète : un ManyToMany qui paraissait évident, un problème de transaction qui n'était pas visible au premier regard, et un refactoring qui a transformé une table de jointure anonyme en une entité JobPosting avec une identité, des attributs et une sémantique propres. Avant de conclure, faisons le bilan honnête — ce qu'on a gagné, et ce qu'on a perdu.

Ce qu'on a gagné

Le premier bénéfice est opérationnel : chaque publication est maintenant dans sa propre transaction. Si LinkedIn est indisponible, Indeed et Welcome to the Jungle ont déjà commité leur enregistrement. La résilience partielle n'est plus un cas à gérer spécialement — c'est le comportement naturel du modèle.

Le deuxième bénéfice est sémantique. $board->post($job) dit ce qu'il fait. Comparer avec l'ancien $job->addJobBoard($board) : quelle entité est l'acteur ? JobBoard — c'est elle qui publie, c'est elle qui est responsable. La méthode métier exprime l'intention.

Le troisième bénéfice est structurel. JobPosting peut maintenant porter toutes les métadonnées qui apparaîtront tôt ou tard dans ton backlog : une date de publication, un statut (pending, published, failed), un nombre de clics, une date d'expiration, un prix facturé. Ces données appartiennent naturellement à l'acte de publier, pas à l'offre ni à la plateforme. Une table de jointure ne peut pas les accueillir — une entité, si.

Ce qu'on a perdu

Soyons directs : $job->getBoards() n'existe plus. Si tu affiches dans une vue admin les plateformes sur lesquelles une offre a été publiée, tu ne peux plus naviguer directement depuis l'entité Job. Tu as besoin d'une requête dédiée.

C'est le compromis explicite du modèle : la relation est unidirectionnelle (JobPosting connaît son Job, Job ignore tout des plateformes), donc tu requêtes JobPosting pour reconstituer la vue. Voici à quoi ressemble un repository prévu pour ça :

// src/Infrastructure/Repository/JobPostingRepository.php
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

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

    /**
     * Retourne toutes les publications d'une offre donnée,
     * triées par date décroissante.
     *
     * @return JobPosting[]
     */
    public function findByJob(Job $job): array
    {
        return $this->createQueryBuilder('p')
            ->andWhere('p.job = :job')
            ->setParameter('job', $job)
            ->addOrderBy('p.postedAt', 'DESC')
            ->getQuery()
            ->getResult();
    }
}

Une méthode, une intention. Dans ta vue admin, tu injectes JobPostingRepository et tu appelles findByJob($job) — tu récupères toutes les publications avec leur statut, leur date et leur plateforme. C'est plus explicite qu'une collection chargée à la volée depuis l'entité, et c'est entièrement contrôlable (pagination, filtre sur le statut, eager loading de jobBoard).

💡 Quand suspecter qu'un ManyToMany cache un problème

Avant de mapper la prochaine relation ManyToMany, pose-toi ces quatre questions :

  1. La relation mérite-t-elle une identité propre ? Si tu dois retrouver, modifier ou supprimer un lien spécifique entre deux entités (par exemple "la publication de cette offre sur cette plateforme"), c'est qu'il s'agit d'une entité à part entière, pas d'une entrée dans une table de jointure.
  2. Elle a des attributs qui lui appartiennent ? Une date de création, un statut, un prix, un compteur de vues — dès qu'un attribut décrit la relation plutôt que l'une ou l'autre des entités liées, la relation est une entité.
  3. Les deux côtés peuvent évoluer indépendamment ? Si Job peut changer de titre sans affecter les JobPosting existants, et si une JobBoard peut changer de nom sans annuler les publications passées, les deux agrégats ont des cycles de vie distincts — un ManyToMany introduit un couplage artificiel.
  4. Une défaillance partielle est acceptable métier ? Si publier sur trois plateformes sur cinq est préférable à ne publier nulle part, alors les cinq opérations doivent pouvoir échouer indépendamment. Une seule transaction pour toutes les associations rend ça impossible.

Si tu réponds oui à au moins deux de ces questions, le ManyToMany cache très probablement une entité de relation que ton modèle n'a pas encore nommée.

La vraie question à se poser

Les ManyToMany sont séduisants parce qu'ils fonctionnent. Doctrine les supporte nativement, la migration s'écrit en une ligne, et ton premier écran d'affichage marche du premier coup. Le problème apparaît plus tard, quand le métier demande "pourquoi cette offre n'est pas parue sur Indeed ?" — et que tu n'as aucune trace de l'échec, aucun statut, aucun moyen de relancer.

La question à se poser devant chaque relation bidirectionnelle n'est pas "est-ce que Doctrine gère ça ?", mais :

🔑 Qui est responsable de cette association, et que se passe-t-il si une partie échoue ?
Si la réponse implique plusieurs acteurs distincts et une possibilité d'échec partiel, tu as besoin d'une entité, pas d'une jointure.

C'est la question qui révèle la modélisation juste. Pas "est-ce que c'est un ManyToMany ?", mais "qui signe ce contrat, et qu'est-ce qu'on fait quand il n'aboutit pas ?"

Ce raisonnement est inspiré d'un article de Udi Dahan — DDD & Many to Many Object Relational Mapping (2009) — court, direct, et toujours d'actualité si tu veux creuser les implications sur les agrégats et les limites de cohérence.