dynamische Formulare
Sobald ein Formular eine variable Anzahl gleichartiger Unterformulare verwalten soll, etwa Positionen einer Bestellung oder Tags eines Artikels, reicht ein einzelnes Feld nicht mehr aus. CollectionType löst genau dieses Problem, erfordert aber ein Zusammenspiel aus allow_add/allow_delete, korrektem Entity-Mapping, Prototype-basiertem JavaScript und gezielten PRE_SET_DATA- sowie PRE_SUBMIT-Event-Listenern, um robust und sicher zu funktionieren.
Inhaltsverzeichnis
- 1. Wann eine feste Formularstruktur nicht mehr ausreicht
- 2. CollectionType-Grundkonfiguration: entry_type, allow_add, allow_delete
- 3. by_reference: false und Adder/Remover-Methoden auf der Entity
- 4. Das Prototype-Attribut und JavaScript-Integration ohne Page-Reload
- 5. Einträge im Browser entfernen, ohne die Seite neu zu laden
- 6. PRE_SET_DATA: eine initiale leere Zeile bei neuen Entities hinzufügen
- 7. PRE_SUBMIT: Absicherung gegen zu viele eingereichte Einträge
- 8. delete_empty und Validierung der Collection
- 9. Praxis-Tipps und Fazit
- 10. Zusammenfassung
- 11. FAQ
1. Wann eine feste Formularstruktur nicht mehr ausreicht
Ein klassisches Symfony-Formular bildet eine feste, zur Entwicklungszeit bekannte Menge an Feldern ab, was für die meisten Anwendungsfälle vollkommen ausreicht. Sobald ein Nutzer aber selbst bestimmen soll, wie viele Einträge eines bestimmten Typs er anlegt, etwa beliebig viele Positionen in einer Bestellung, mehrere Ansprechpartner bei einer Firma oder eine variable Anzahl Tags für einen Artikel, stößt diese feste Struktur an ihre Grenzen, weil die Anzahl der Felder erst zur Laufzeit feststeht und sich während der Bearbeitung sogar noch ändern kann.
Die Form-Component löst dieses Problem über CollectionType, einen Feldtyp, der intern eine Sammlung gleichartiger Unterformulare verwaltet und dabei automatisch für jedes Element der zugrunde liegenden Datensammlung, etwa einem Doctrine-Collection-Property, ein eigenes Unterformular vom Typ entry_type erzeugt. CollectionType übernimmt dabei sowohl das Rendering aller Unterformulare als auch das Mapping der eingereichten Daten zurück auf die Sammlung, sofern Entity-Mapping und JavaScript korrekt zusammenspielen, was in der Praxis der aufwendigere Teil der Implementierung ist.
2. CollectionType-Grundkonfiguration: entry_type, allow_add, allow_delete
Die wichtigste Option ist entry_type, die den Formulartyp jedes einzelnen Eintrags festlegt, etwa OrderItemType für jede Position einer Bestellung, während entry_options zusätzliche Optionen an jede Instanz dieses Unterformulars weiterreicht, etwa label: false, um Labels für jeden Eintrag individuell im Template zu steuern statt sie automatisch generieren zu lassen. Ohne weitere Konfiguration bildet CollectionType lediglich eine feste Anzahl Unterformulare ab, die genau der Anzahl der Elemente in der zugrunde liegenden Sammlung zum Zeitpunkt des Renderings entspricht, ohne dass Nutzer im Browser weitere Einträge hinzufügen könnten.
allow_add erlaubt es, beim Absenden mehr Einträge zu übermitteln als ursprünglich im Formular gerendert wurden, während allow_delete das Gegenteil erlaubt: weniger Einträge zu übermitteln, als ursprünglich vorhanden waren, wobei die fehlenden Einträge beim Mapping auf die Entity-Sammlung als entfernt behandelt werden. Beide Optionen betreffen ausschließlich die serverseitige Verarbeitung eingereichter Daten und erzeugen selbst keinerlei Buttons oder JavaScript im Frontend, was ein häufiges Missverständnis bei der ersten Nutzung ist.
<?php
// src/Form/OrderType.php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Order;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class OrderType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('items', CollectionType::class, [
'entry_type' => OrderItemType::class,
'entry_options' => ['label' => false],
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true,
'prototype_name' => '__item_index__',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Order::class]);
}
}
3. by_reference: false und Adder/Remover-Methoden auf der Entity
Standardmäßig arbeitet die Form-Component bei Sammlungen über by_reference: true, was bedeutet, dass der PropertyAccessor lediglich das zurückgegebene Collection-Objekt direkt manipuliert, ohne einen expliziten Setter aufzurufen. Für CollectionType mit allow_add oder allow_delete ist das problematisch, weil das Hinzufügen oder Entfernen einzelner Einträge dann an der Entity vorbeigeht, insbesondere wenn die Gegenseite einer bidirektionalen Doctrine-Beziehung, etwa das Setzen der Order-Referenz auf jedem OrderItem, über eine eigene Adder-Methode wie addItem() erfolgen soll.
Mit by_reference: false ruft der PropertyAccessor stattdessen für jeden neuen Eintrag addItem() und für jeden entfernten Eintrag removeItem() auf der Order-Entity auf, sofern beide Methoden nach der üblichen Namenskonvention vorhanden sind. Genau in diesen Adder- und Remover-Methoden lassen sich dann Nebenwirkungen wie das Setzen der inversen Beziehungsseite korrekt abbilden, was ohne by_reference: false schlicht nicht zuverlässig funktioniert, weil Doctrine sonst nur die eine Seite der Beziehung aktualisiert bekommt und beim nächsten Laden der Entity inkonsistente Daten liefert.
4. Das Prototype-Attribut und JavaScript-Integration ohne Page-Reload
Wenn prototype aktiv ist, was standardmäßig der Fall ist, rendert Twig für das CollectionType-Feld ein data-prototype-Attribut auf dem umschließenden Element, das ein vollständiges, aber deaktiviertes Unterformular mit einem Platzhalter wie __item_index__ anstelle eines echten Index enthält. Ein Stimulus-Controller oder reines Vanilla-JavaScript liest dieses Attribut aus, ersetzt den Platzhalter durch einen tatsächlich eindeutigen, bislang nicht verwendeten Index und fügt das resultierende HTML-Fragment als neue Zeile in den DOM ein, ganz ohne Server-Roundtrip oder Page-Reload.
Wichtig ist, den Index bei jedem Hinzufügen strikt eindeutig zu halten, weil doppelte Indizes beim Absenden zu unvorhersehbarem Verhalten führen können. Ein einfacher, hochzählender Zähler, der beim Laden der Seite mit der aktuellen Anzahl bereits vorhandener Einträge initialisiert wird, ist dafür meist ausreichend und robuster als etwa ein zufälliger Wert, weil die Reihenfolge der Einträge beim erneuten Rendern nach einem fehlgeschlagenen Validierungsversuch damit stabil und nachvollziehbar bleibt.
// assets/controllers/collection_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['list', 'template'];
connect() {
this.index = this.listTarget.children.length;
}
addItem() {
const html = this.templateTarget
.dataset
.prototype
.replace(/__item_index__/g, this.index);
this.listTarget.insertAdjacentHTML('beforeend', html);
this.index++;
}
removeItem(event) {
event.target.closest('[data-collection-target="entry"]').remove();
}
}
5. Einträge im Browser entfernen, ohne die Seite neu zu laden
Das Entfernen eines Eintrags im Browser ist rein clientseitig deutlich einfacher als das Hinzufügen, weil dafür lediglich der DOM-Knoten der jeweiligen Zeile entfernt werden muss, ohne dass ein neuer Prototype-Block erzeugt oder ein Index berechnet werden müsste. Entscheidend ist, dass jeder Löschen-Button direkt im umschließenden Element seiner Zeile sitzt oder über ein data-Attribut eindeutig referenziert, welche Zeile entfernt werden soll, damit der Klick-Handler zuverlässig den richtigen Knoten trifft, auch wenn zwischenzeitlich weitere Zeilen hinzugefügt oder entfernt wurden.
Serverseitig muss für entfernte Einträge nichts Besonderes getan werden, solange allow_delete aktiv und by_reference auf false gesetzt ist: Da der entfernte Eintrag beim Absenden schlicht nicht mehr im übermittelten Datenarray auftaucht, erkennt CollectionType automatisch, dass dieser Eintrag aus der Sammlung entfernt werden soll, und ruft die entsprechende Remover-Methode auf der Entity auf. Ein häufiger Fehler ist, zusätzlich noch ein verstecktes Löschen-Flag pro Zeile einzubauen, was bei korrekt konfiguriertem allow_delete unnötig ist und die Formularlogik nur unnötig verkompliziert.
6. PRE_SET_DATA: eine initiale leere Zeile bei neuen Entities hinzufügen
Bei einer bereits bestehenden Order mit gespeicherten Positionen rendert CollectionType automatisch für jede vorhandene Position ein Unterformular, weil die zugrunde liegende Datensammlung zum Zeitpunkt des Renderings bereits Einträge enthält. Bei einer neuen, noch leeren Order ist die Sammlung dagegen leer, sodass ohne weiteres Zutun kein einziges Unterformular gerendert wird und der Nutzer zunächst aktiv auf Hinzufügen klicken müsste, bevor überhaupt eine erste Position sichtbar ist, was für die Nutzerführung meist ungünstig ist.
Ein FormEvents::PRE_SET_DATA-Listener auf dem übergeordneten FormType löst dieses Problem, indem er vor dem eigentlichen Setzen der Formulardaten prüft, ob die Sammlung leer ist, und in diesem Fall programmatisch ein leeres OrderItem-Objekt in die Sammlung einfügt, sodass beim Rendern automatisch eine erste, leere Zeile sichtbar wird. Dieser Ansatz ist robuster als eine reine JavaScript-Lösung, weil er auch beim ersten Server-Rendering ohne aktiviertes JavaScript bereits eine sichtbare Zeile liefert, was insbesondere für Barrierefreiheit und progressive Enhancement relevant ist.
<?php
// src/Form/OrderType.php (Ausschnitt)
declare(strict_types=1);
use App\Entity\OrderItem;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
$order = $event->getData();
if ($order !== null && $order->getItems()->isEmpty()) {
$order->addItem(new OrderItem());
}
});
7. PRE_SUBMIT: Absicherung gegen zu viele eingereichte Einträge
allow_add bringt ein oft übersehenes Sicherheitsrisiko mit sich: Da CollectionType prinzipiell akzeptiert, dass mehr Einträge eingereicht werden als ursprünglich gerendert wurden, kann ein Angreifer, der die Formular-Payload manuell manipuliert, ein Array mit zehntausenden Einträgen einreichen, wodurch Symfony versucht, für jeden dieser Einträge ein vollständiges Unterformular samt Validierung aufzubauen, was zu spürbarer Serverlast oder im Extremfall zu einem Denial-of-Service führen kann.
Ein FormEvents::PRE_SUBMIT-Listener greift genau an der richtigen Stelle ein, weil er Zugriff auf die rohen, noch nicht verarbeiteten Submit-Daten als assoziatives Array hat, bevor die Form-Component daraus tatsächlich Unterformulare aufbaut. Der Listener kann prüfen, ob das Array für das items-Feld eine konfigurierte Obergrenze überschreitet, und in diesem Fall die überzähligen Einträge vor der Weiterverarbeitung abschneiden, sodass niemals mehr Unterformulare aufgebaut werden, als die Anwendung fachlich sinnvoll zulassen soll.
<?php
// src/Form/OrderType.php (Ausschnitt)
declare(strict_types=1);
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
private const MAX_ITEMS = 50;
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void {
$data = $event->getData();
if (isset($data['items']) && \count($data['items']) > self::MAX_ITEMS) {
$data['items'] = \array_slice($data['items'], 0, self::MAX_ITEMS, true);
$event->setData($data);
}
});
8. delete_empty und Validierung der Collection
Die Option delete_empty entfernt automatisch alle Einträge aus der Sammlung, deren Unterformular-Daten nach der Verarbeitung dem leeren Standardwert des entry_type entsprechen, was sich anbietet, wenn Nutzer eine leere Zeile stehen lassen, statt sie explizit über den Löschen-Button zu entfernen. Ohne delete_empty würde eine solche leere Zeile als vollwertiger, aber inhaltsleerer Eintrag in der Sammlung landen und müsste anschließend über Validierungsconstraints oder manuelle Nachbearbeitung im Controller abgefangen werden.
Für die Gesamtzahl der Einträge empfiehlt sich zusätzlich eine Symfony-Validator-Constraint direkt auf der Collection-Property der Entity, etwa Count mit min und max, damit die fachliche Regel, wie viele Positionen eine Bestellung mindestens oder höchstens haben darf, unabhängig vom Formular greift und auch bei einer direkten Erstellung der Entity außerhalb des Formulars, etwa über eine API oder einen Import, konsistent durchgesetzt wird.
9. Praxis-Tipps und Fazit
In der Praxis bewährt sich die Kombination aus allen vorgestellten Bausteinen: allow_add und allow_delete für die grundsätzliche Flexibilität, by_reference: false zusammen mit Adder- und Remover-Methoden für korrektes Entity-Mapping, ein PRE_SET_DATA-Listener für eine nutzerfreundliche initiale Zeile und ein PRE_SUBMIT-Listener als Sicherheitsnetz gegen überdimensionierte Submit-Payloads. Jeder dieser Bausteine löst ein spezifisches Teilproblem und lässt sich unabhängig von den anderen testen, was die gesamte Implementierung trotz ihrer mehreren beweglichen Teile gut wartbar hält.
Wer CollectionType neu einführt, sollte zunächst mit einer festen Anzahl Einträge ohne allow_add beginnen, um das Entity-Mapping und die Validierung zu verifizieren, bevor JavaScript-Interaktion und PRE_SUBMIT-Absicherung hinzukommen. Diese schrittweise Vorgehensweise trennt die drei unabhängigen Fehlerquellen, Mapping, Frontend-Interaktion und Sicherheit, sauber voneinander und macht die Fehlersuche bei ersten Problemen deutlich einfacher als eine sofortige Implementierung aller Bausteine gleichzeitig.
| Baustein | Zweck | Wo konfiguriert | Typisches Problem ohne diesen Baustein |
|---|---|---|---|
| allow_add / allow_delete | Variable Anzahl Einträge beim Submit akzeptieren | CollectionType-Optionen | Nur exakt die ursprünglich gerenderte Anzahl wird akzeptiert |
| by_reference: false + Adder/Remover | Korrektes Mapping auf die Entity-Sammlung | CollectionType-Option + Entity-Methoden | Inverse Beziehungsseite bleibt inkonsistent |
| Prototype + JavaScript | Neue Zeilen ohne Page-Reload hinzufügen | Twig data-prototype + Stimulus/JS | Nutzer kann nur die initial gerenderten Zeilen sehen |
| PRE_SET_DATA-Listener | Initiale leere Zeile bei neuen Entities | FormType-Klasse | Leeres Formular ohne sichtbare erste Zeile |
| PRE_SUBMIT-Listener | Obergrenze für eingereichte Einträge durchsetzen | FormType-Klasse | Beliebig große Payloads können den Server belasten |
Mironsoft
Symfony-Architektur, saubere Domänenlogik und Legacy-Modernisierung
Symfony-Anwendungen, die nach zwei Jahren noch wartbar sind?
Wir prüfen bestehende Symfony-Projekte auf aufgeblähte Controller, fehlende Serviceabstraktionen und ungetestete Kernlogik und bauen daraus eine Architektur, die neue Features aufnimmt, ohne bei jedem Release brüchiger zu werden.
Architektur-Review
Bundle-Struktur, Dependency Injection und Serviceabstraktionen auf Wartbarkeit prüfen.
Legacy-Modernisierung
Schrittweise Migration veralteter Symfony-Versionen ohne kompletten Neuschrieb.
Test- und Qualitätssicherung
PHPUnit, PHPStan und CI-Pipelines für nachhaltige Codequalität aufbauen.
10. Zusammenfassung
CollectionType: Das Wichtigste auf einen Blick
Kernproblem
Eine feste Formularstruktur kann keine zur Laufzeit variable Anzahl gleichartiger Unterformulare abbilden.
Lösung
CollectionType mit allow_add/allow_delete, by_reference: false und Adder/Remover-Methoden auf der Entity.
Frontend-Integration
Prototype-Attribut per JavaScript klonen fügt neue Zeilen ohne Page-Reload hinzu.
Sicherheit
PRE_SUBMIT begrenzt die Anzahl eingereichter Einträge gegen überdimensionierte Payloads.