Collection de formulaires (CollectionType)
Comment afficher, dans un même formulaire, plusieurs sous-formulaires qu'on peut ajouter ou supprimer dynamiquement (par exemple, les réponses d'une question de quiz)

Petite vérification
Avant de commencer, vérifiez si vous savez...
- citer la commande Symfony qui génère interactivement une nouvelle entité Doctrine
- citer la commande qui génère un fichier de migration à partir des entités modifiées, puis celle qui exécute cette migration sur la base de données
📌 Réponse attendue
Notions théoriques
Le nouveau projet : un Quiz avec ses réponses
Dans cette séance, on quitte le jeu de personnages pour un nouveau petit projet Symfony : une application qui gère des questions de quiz, chacune ayant plusieurs réponses possibles. Une question (Question) possède donc une collection de réponses (Answer), et chaque réponse appartient à une seule question.
Voici les deux entités déjà créées pour ce projet (après avoir répondu aux questions interactives de make:entity) :
// src/Entity/Question.php
namespace App\Entity;
use App\Repository\QuestionRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: QuestionRepository::class)]
class Question
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $text = null;
#[ORM\Column(type: Types::TEXT, nullable: true)]
private ?string $explication = null;
/**
* @var Collection<int, Answer>
*/
#[ORM\OneToMany(targetEntity: Answer::class, mappedBy: 'question', orphanRemoval: true, cascade: ['persist'])]
private Collection $answers;
public function __construct()
{
$this->answers = new ArrayCollection();
}
// ... getId(), getText(), setText(), getExplication(), setExplication() ...
public function getAnswers(): Collection
{
return $this->answers;
}
public function addAnswer(Answer $answer): static
{
if (!$this->answers->contains($answer)) {
$this->answers->add($answer);
$answer->setQuestion($this);
}
return $this;
}
public function removeAnswer(Answer $answer): static
{
if ($this->answers->removeElement($answer)) {
if ($answer->getQuestion() === $this) {
$answer->setQuestion(null);
}
}
return $this;
}
}
// src/Entity/Answer.php
namespace App\Entity;
use App\Repository\AnswerRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity(repositoryClass: AnswerRepository::class)]
class Answer
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private ?int $id = null;
#[ORM\Column(type: Types::TEXT)]
private ?string $text = null;
#[ORM\Column]
private ?bool $is_correct = false;
#[ORM\ManyToOne(inversedBy: 'answers')]
#[ORM\JoinColumn(nullable: false)]
private ?Question $question = null;
// ... getId(), getText(), setText() ...
public function isCorrect(): ?bool
{
return $this->is_correct;
}
public function setIsCorrect(bool $is_correct): static
{
$this->is_correct = $is_correct;
return $this;
}
public function getQuestion(): ?Question
{
return $this->question;
}
public function setQuestion(?Question $question): static
{
$this->question = $question;
return $this;
}
}
$is_correct = false dans la déclaration de la propriété ?Une case à cocher non cochée n'envoie aucune donnée au serveur (c'est une règle des formulaires HTML, pas de Symfony). Si la propriété n'avait pas de valeur par défaut, Symfony essaierait d'enregistrer null dans une colonne qui n'accepte pas cette valeur, et l'enregistrement échouerait. La valeur par défaut false évite cette erreur.
Rappel : les commandes déjà exécutées pour ce nouveau projet
Avant d'arriver à cette séance, vous avez déjà exécuté, dans cet ordre, les commandes suivantes :
php bin/console make:entity
Cette commande a été lancée deux fois : une première fois pour créer Question (avec ses champs text et explication), une seconde fois pour créer Answer (avec ses champs text et isCorrect). C'est au cours de la création de Answer que make:entity a posé des questions supplémentaires pour créer la relation avec Question : le type de relation (ManyToOne), le nom de la propriété inverse (answers), et surtout deux questions essentielles :
- "Is the Answer.question property allowed to be null (nullable)?" → répondu no, car une réponse sans question n'a pas de sens : d'où
#[ORM\JoinColumn(nullable: false)]. - "Do you want to activate orphanRemoval on your relationship?" → répondu yes.
php bin/console make:migration
php bin/console doctrine:migrations:migrate
Ces deux commandes, déjà vues dans la séance « Migrations avec Doctrine », ont ensuite créé les tables question et answer en base de données, avec la clé étrangère question_id dans answer.
php bin/console make:crud Question
Cette commande a généré un QuestionController complet (actions index, new, show, edit, delete) ainsi que les templates Twig correspondants, en se basant uniquement sur les champs simples de Question (text, explication). Elle ne sait pas générer de formulaire pour une relation OneToMany : le champ answers n'apparaît donc dans aucun des formulaires générés. C'est tout l'objet de cette séance : ajouter manuellement ce champ grâce à CollectionType, pour pouvoir créer ou modifier une question avec ses réponses, en un seul formulaire.
orphanRemoval: true, placé sur le #[ORM\OneToMany] de Question, signifie : "si une Answer est retirée de la collection $answers d'une Question (par exemple avec removeAnswer()), alors cette Answer doit être supprimée de la base de données". Sans cette option, Doctrine se contenterait de mettre question_id à null dans la table answer, ce qui provoquerait une erreur (la colonne est nullable: false), ou laisserait des réponses orphelines, détachées de toute question.
cascade: ['persist'], sur la même relation, signifie : "quand on enregistre (persist()) une Question, enregistrer automatiquement avec elle toutes les Answer nouvellement ajoutées à sa collection". Sans cette option, il faudrait appeler $entityManager->persist($answer) séparément pour chaque réponse nouvellement créée, ce qu'on va justement vouloir éviter dans le contrôleur de cette séance.
Le problème à résoudre : un formulaire, plusieurs sous-formulaires
On veut un formulaire Symfony unique qui permette de créer ou modifier une Question, et en même temps d'ajouter, modifier ou supprimer autant de Answer que l'utilisateur le souhaite, sans recharger la page à chaque ajout.
Pour cela, Symfony fournit un type de champ spécial : CollectionType. Il transforme une propriété qui contient une collection d'objets (ici $answers, de type Collection<Answer>) en une collection de sous-formulaires (ici, un AnswerType par réponse).
AnswerType : le formulaire d'une seule réponse
Avant de pouvoir afficher une collection de formulaires Answer, il faut d'abord un formulaire classique, à l'unité, pour une seule Answer :
// src/Form/AnswerType.php
namespace App\Form;
use App\Entity\Answer;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class AnswerType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('text', TextType::class, [
'label' => 'Texte de la réponse',
])
->add('isCorrect', CheckboxType::class, [
'label' => 'Bonne réponse ?',
'required' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Answer::class,
]);
}
}
Le champ du formulaire s'appelle isCorrect (sans underscore), même si la propriété de l'entité s'appelle $is_correct. Symfony retrouve automatiquement les méthodes isCorrect() et setIsCorrect() de l'entité à partir du nom isCorrect donné au champ : il n'est pas obligatoire que le nom du champ soit identique au nom exact de la propriété, seulement qu'il corresponde à des méthodes get/is/set existantes. 'required' => false est nécessaire ici : une case à cocher peut légitimement rester décochée.
QuestionType : le formulaire principal, avec la collection
On ajoute maintenant à QuestionType un champ answers de type CollectionType :
// src/Form/QuestionType.php
namespace App\Form;
use App\Entity\Question;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class QuestionType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('text', TextareaType::class, [
'label' => 'Énoncé de la question',
])
->add('explication', TextareaType::class, [
'label' => 'Explication (optionnelle)',
'required' => false,
])
->add('answers', CollectionType::class, [
'entry_type' => AnswerType::class,
'entry_options' => ['label' => false],
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Question::class,
]);
}
}
Détaillons chaque option de ce champ answers, une par une, car c'est le cœur de la séance :
entry_type: AnswerType::class: indique que chaque élément de la collection$answersdoit être affiché avec le formulaireAnswerType(celui qu'on vient d'écrire), pas avec un simple champ texte.entry_options: ['label' => false]: options transmises à chaque sous-formulaireAnswerTypegénéré. Ici, on désactive le label global de chaque bloc « réponse », car on affichera déjà un label pour chacun de ses champs internes (text,isCorrect).allow_add: true: sans cette option, Symfony refuserait qu'on soumette une réponse supplémentaire non prévue au départ (protection par défaut, pour éviter qu'un utilisateur malveillant n'injecte des données inattendues). Il faut l'activer explicitement pour permettre l'ajout dynamique de réponses.allow_delete: true: même principe, mais pour la suppression. Sans cette option, Symfony refuserait qu'une réponse présente au départ disparaisse de la soumission.by_reference: false: option la plus subtile, expliquée en détail juste après.
by_reference: false : pourquoi c'est indispensable iciPar défaut (by_reference: true), quand Symfony doit ajouter un objet Answer à la collection de Question, il modifie directement le tableau interne de la collection, sans jamais appeler addAnswer(). Or c'est justement addAnswer() qui contient la ligne essentielle $answer->setQuestion($this); : sans elle, chaque nouvelle Answer se retrouverait avec une propriété $question à null, ce qui provoquerait une erreur en base (question_id ne peut pas être null).
Avec by_reference: false, Symfony est obligé d'appeler la méthode addAnswer() de l'entité Question pour ajouter une réponse, et removeAnswer() pour en retirer une. C'est exactement le mécanisme qui permet à orphanRemoval de fonctionner : c'est removeAnswer() qui, en retirant l'Answer de la collection, déclenche sa suppression en base au prochain flush().
Afficher la collection dans le formulaire Twig, réponse par réponse
Pour afficher le formulaire, on ne peut pas se contenter de {{ form_row(form.answers) }} : cela afficherait bien les réponses déjà existantes, mais aucun moyen d'en ajouter une nouvelle. Il faut construire la liste manuellement, en Twig :
{# templates/question/new.html.twig #}
{{ form_start(form) }}
{{ form_row(form.text) }}
{{ form_row(form.explication) }}
<h3>Réponses</h3>
<ul class="answers"
data-index="{{ form.answers|length > 0 ? form.answers|last.vars.name + 1 : 0 }}"
data-prototype="{{ form_widget(form.answers.vars.prototype)|e('html_attr') }}"
>
{% for answer in form.answers %}
<li>{{ form_row(answer) }}</li>
{% endfor %}
</ul>
<button type="button" class="add_item_link" data-collection-holder-class="answers">Ajouter une réponse</button>
<button type="submit">Enregistrer</button>
{{ form_end(form) }}
Décomposons cette boucle, ligne par ligne, car chaque détail compte :
{% for answer in form.answers %}{{ form_row(answer) }}{% endfor %}affiche un<li>pour chaque réponse déjà présente (celles déjà en base pour une question existante, ou celles ajoutées par le contrôleur pour une question neuve).data-index="..."stocke, dans un attribut HTML personnalisé, le prochain numéro disponible pour une réponse. S'il y a déjà 2 réponses (index0et1), le prochain index sera2. Ce numéro servira tout à l'heure en JavaScript.data-prototype="..."est l'attribut le plus important de toute la séance :form.answers.vars.prototypeest un formulaireAnswerTypecomplet, mais vide, que Symfony génère automatiquement dès qu'on activeallow_add: true.form_widget(...)le transforme en HTML, et|e('html_attr')échappe ce HTML pour qu'il puisse être stocké sans problème à l'intérieur d'un attribut HTML (les guillemets du HTML du formulaire seraient sinon confondus avec les guillemets de l'attributdata-prototypelui-même).
__name__ caché dans le prototypeLe HTML généré pour le prototype contient le texte littéral __name__ à la place du numéro de la réponse, par exemple name="question[answers][__name__][text]". C'est un espace réservé que le JavaScript devra remplacer par un vrai numéro (0, 1, 2...) avant d'insérer ce prototype dans la page. On ne le voit jamais tel quel à l'écran : il n'existe que dans le texte brut stocké dans data-prototype.
Pourquoi du JavaScript, et pas de Stimulus
Symfony propose, dans sa documentation officielle, une version de cette fonctionnalité utilisant Stimulus (un petit framework JavaScript intégré à Symfony UX). Dans cette séance, on utilise volontairement du JavaScript classique (« vanilla »), sans aucune bibliothèque, pour bien comprendre ce qui se passe réellement dans le navigateur, avant d'étudier éventuellement Stimulus dans une séance ultérieure.
Le JavaScript doit répondre à 2 besoins, et à 2 seulement :
- Au clic sur le bouton « Ajouter une réponse », cloner le prototype (en remplaçant
__name__par le bon index), puis l'insérer dans la liste<ul class="answers">. - Pour chaque réponse affichée (qu'elle existait déjà ou qu'elle vienne d'être ajoutée), ajouter un bouton « Supprimer cette réponse » qui retire son
<li>de la page.
// assets/app.js (ou un fichier dédié, importé par app.js)
document
.querySelectorAll('.add_item_link')
.forEach((btn) => {
btn.addEventListener('click', addFormToCollection);
});
document
.querySelectorAll('ul.answers li')
.forEach((answer) => {
addAnswerFormDeleteLink(answer);
});
function addFormToCollection(e) {
const collectionHolder = document.querySelector('.' + e.currentTarget.dataset.collectionHolderClass);
const item = document.createElement('li');
item.innerHTML = collectionHolder
.dataset
.prototype
.replace(/__name__/g, collectionHolder.dataset.index);
collectionHolder.appendChild(item);
addAnswerFormDeleteLink(item);
collectionHolder.dataset.index++;
}
function addAnswerFormDeleteLink(item) {
const removeFormButton = document.createElement('button');
removeFormButton.innerText = 'Supprimer cette réponse';
item.append(removeFormButton);
removeFormButton.addEventListener('click', (e) => {
e.preventDefault();
item.remove();
});
}
Reprenons cette fonction addFormToCollection, instruction par instruction :
e.currentTarget.dataset.collectionHolderClassrécupère"answers", la valeur de l'attributdata-collection-holder-classdu bouton cliqué.document.querySelector('.' + ...)retrouve donc l'élément<ul class="answers">: c'estcollectionHolder.document.createElement('li')crée un nouvel élément<li>, encore vide, uniquement en mémoire (pas encore affiché).collectionHolder.dataset.prototype.replace(/__name__/g, collectionHolder.dataset.index)prend le texte brut stocké dansdata-prototype, et remplace toutes les occurrences (/g= global) de__name__par l'index courant (par exemple2). On obtient ainsi le HTML d'un vrai formulaireAnswerType, prêt à être soumis sous le nomquestion[answers][2][...].item.innerHTML = ...place ce HTML à l'intérieur du<li>créé à l'étape 3.collectionHolder.appendChild(item)insère enfin ce<li>dans la page, à la fin de la liste<ul>. C'est seulement à partir de cette ligne que la nouvelle réponse est visible.addAnswerFormDeleteLink(item)ajoute immédiatement un bouton de suppression à cette nouvelle réponse (sinon elle ne pourrait jamais être retirée avant d'être envoyée).collectionHolder.dataset.index++incrémente l'index stocké, pour que la prochaine réponse ajoutée obtienne un numéro différent (3, puis4...), et jamais le même numéro deux fois.
La fonction addAnswerFormDeleteLink est plus courte : elle crée un <button>, l'ajoute à la fin du <li> reçu en paramètre, puis lui attache un gestionnaire de clic qui appelle item.remove(). e.preventDefault() est indispensable : sans lui, le clic sur ce bouton (placé à l'intérieur d'un <form>) soumettrait le formulaire entier au lieu de simplement retirer la réponse.
item.remove() ne supprime rien en base de donnéesCette fonction JavaScript agit uniquement dans le navigateur, avant l'envoi du formulaire. Retirer un <li> de la page ne supprime aucune ligne dans la table answer. C'est seulement lors de la soumission du formulaire que Symfony compare les réponses envoyées à celles déjà en base, appelle removeAnswer() sur celles qui ont disparu, et c'est orphanRemoval: true qui provoque alors leur suppression réelle, au moment du flush().
Test de mémorisation/compréhension
TP pour réfléchir et résoudre des problèmes
Votre défi pour aujourd'hui consiste à permettre la modification d'une question existante : charger ses réponses actuelles, pouvoir en ajouter de nouvelles et en supprimer, le tout dans un seul formulaire.
Étape 1 — Vérifier le champ isCorrect dans AnswerType
required: false sur une case à cocherUne case à cocher non cochée n'envoie aucune valeur au serveur. Si le champ reste required: true (valeur par défaut), Symfony considère une case décochée comme une erreur de validation, alors que « non coché » est souvent une réponse parfaitement valide.
Étape 2 — Écrire l'action edit() du contrôleur
L'action edit() doit charger la question existante avant de créer le formulaire (contrairement à new(), qui crée une Question toute neuve).
persist() dans edit()Contrairement à new(), l'action edit() n'appelle jamais $entityManager->persist($question) : la Question existe déjà en base, Doctrine la suit déjà (elle est « managée »). Un simple flush() suffit à enregistrer ses modifications, y compris celles de sa collection $answers grâce à cascade: ['persist'] et orphanRemoval: true.
Étape 3 — Afficher le bouton de suppression pour chaque réponse déjà existante
La même fonction addAnswerFormDeleteLink() sert pour les réponses déjà présentes au chargement et pour les réponses ajoutées dynamiquement ensuite. Écrire deux fonctions différentes (une pour chaque cas) dupliquerait inutilement le code, et obligerait à corriger le même bug à deux endroits si un jour il fallait le corriger.
Étape 4 — Remplacer __name__ par l'index réel
/g est indispensableUne expression régulière sans le drapeau /g ne remplace que la première occurrence trouvée. Or le prototype d'un formulaire AnswerType contient __name__ à plusieurs endroits (dans le name de chaque champ : text et isCorrect, et dans l'attribut id de chaque champ). Oublier /g laisserait certains champs avec un nom invalide.