Aller au contenu principal

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)

Schéma

Petite vérification​

Avant de commencer, vérifiez si vous savez...

  1. citer la commande Symfony qui génère interactivement une nouvelle entité Doctrine
  2. 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;
}
}
Pourquoi $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 : une précision indispensable

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,
]);
}
}
'isCorrect', pas 'is_correct'

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 $answers doit être affiché avec le formulaire AnswerType (celui qu'on vient d'écrire), pas avec un simple champ texte.
  • entry_options: ['label' => false] : options transmises à chaque sous-formulaire AnswerType gé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 ici

Par 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 (index 0 et 1), le prochain index sera 2. 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.prototype est un formulaire AnswerType complet, mais vide, que Symfony génère automatiquement dès qu'on active allow_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'attribut data-prototype lui-même).
Le __name__ caché dans le prototype

Le 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 :

  1. 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">.
  2. 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 :

  1. e.currentTarget.dataset.collectionHolderClass récupère "answers", la valeur de l'attribut data-collection-holder-class du bouton cliqué.
  2. document.querySelector('.' + ...) retrouve donc l'élément <ul class="answers"> : c'est collectionHolder.
  3. document.createElement('li') crée un nouvel élément <li>, encore vide, uniquement en mémoire (pas encore affiché).
  4. collectionHolder.dataset.prototype.replace(/__name__/g, collectionHolder.dataset.index) prend le texte brut stocké dans data-prototype, et remplace toutes les occurrences (/g = global) de __name__ par l'index courant (par exemple 2). On obtient ainsi le HTML d'un vrai formulaire AnswerType, prêt à être soumis sous le nom question[answers][2][...].
  5. item.innerHTML = ... place ce HTML à l'intérieur du <li> créé à l'étape 3.
  6. 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.
  7. 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).
  8. collectionHolder.dataset.index++ incrémente l'index stocké, pour que la prochaine réponse ajoutée obtienne un numéro différent (3, puis 4...), 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ées

Cette 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​


Quel type de champ Symfony permet d'afficher une collection d'objets sous forme de plusieurs sous-formulaires ?


À quoi sert l'option `entry_type` d'un champ `CollectionType` ?


Sans l'option `allow_add: true` sur un champ `CollectionType`, que se passe-t-il si on soumet un élément supplémentaire non prévu au départ ?


Que change concrètement l'option `by_reference: false` sur la relation `answers` ?


Pourquoi la méthode `addAnswer()` de l'entité `Question` est-elle indispensable (plutôt qu'un simple ajout direct à la collection) ?


Que représente `form.answers.vars.prototype` dans le template Twig ?


À quoi correspond le texte `__name__` présent dans le HTML du prototype ?


Dans `addAnswerFormDeleteLink`, pourquoi faut-il appeler `e.preventDefault()` au clic sur le bouton de suppression ?


`item.remove()`, exécuté en JavaScript au clic sur « Supprimer cette réponse », supprime-t-il la réponse de la base de données ?


Que permet l'option `orphanRemoval: true` placée sur la relation `#[ORM\OneToMany]` de `Question` ?


Pourquoi ajoute-t-on `cascade: ['persist']` sur la relation `OneToMany` de `Question` ?


Que génère la commande `php bin/console make:crud Question`, concernant le champ `answers` ?


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​


Bonne pratique - Toujours required: false sur une case à cocher

Une 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).


Bonne pratique - Pas de 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​


Bonne pratique - Ne pas dupliquer le code d'ajout du bouton

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​


Bonne pratique - /g est indispensable

Une 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.

📌 Une solution