Symfony Forms: CollectionType für dynamische Formulare
AI generated
SF
{ }
Symfony · Forms · Frontend-Integration
Forms: CollectionType für
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.

17 Min. Lesezeit CollectionType Form Events

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.

11. FAQ: CollectionType: Das Wichtigste auf einen Blick

1Was bewirkt allow_add konkret bei CollectionType?
allow_add erlaubt es, beim Absenden mehr Einträge zu übermitteln als ursprünglich gerendert wurden. Ohne diese Option würde die Form-Component zusätzliche, nicht erwartete Einträge im Submit-Array ignorieren oder als Fehler behandeln.
2Warum brauche ich by_reference: false für CollectionType?
Ohne by_reference: false manipuliert der PropertyAccessor die Collection direkt, ohne die Adder- und Remover-Methoden der Entity aufzurufen. Das führt bei bidirektionalen Doctrine-Beziehungen dazu, dass die inverse Seite der Beziehung nicht korrekt gesetzt wird.
3Muss ich für jede Entity mit CollectionType eigene Adder/Remover-Methoden schreiben?
Ja, sofern by_reference: false gesetzt ist, ruft der PropertyAccessor nach Konvention addXxx() und removeXxx() auf der Entity auf. Fehlen diese Methoden, schlägt das Mapping fehl beziehungsweise fällt auf das direkte Collection-Handling zurück.
4Was macht das data-prototype-Attribut?
Es enthält ein vollständiges, deaktiviertes Unterformular mit einem Platzhalter anstelle eines echten Index, das JavaScript klonen und mit einem eindeutigen Index versehen kann, um eine neue Zeile ohne Server-Roundtrip hinzuzufügen.
5Muss ich für das Entfernen einer Zeile serverseitig etwas Besonderes tun?
Nein, solange allow_delete aktiv und by_reference auf false gesetzt ist. Ein beim Submit fehlender Eintrag wird automatisch als entfernt erkannt und die entsprechende Remover-Methode auf der Entity aufgerufen.
6Wofür genau brauche ich einen PRE_SET_DATA-Listener bei CollectionType?
Vor allem dafür, bei einer neuen, noch leeren Entity eine erste, leere Zeile programmatisch in die Sammlung einzufügen, damit das Formular beim ersten Rendering nicht komplett leer erscheint und der Nutzer sofort ein Eingabefeld sieht.
7Warum ist ein PRE_SUBMIT-Listener bei allow_add sicherheitsrelevant?
Weil allow_add prinzipiell akzeptiert, dass beliebig viele Einträge eingereicht werden. Ohne eine Obergrenze könnte ein manipuliertes Submit-Array mit zehntausenden Einträgen erhebliche Serverlast durch den Aufbau ebenso vieler Unterformulare erzeugen.
8Was macht delete_empty genau?
delete_empty entfernt nach der Verarbeitung automatisch alle Einträge, deren Unterformular-Daten dem leeren Standardwert des entry_type entsprechen, sodass leer gelassene Zeilen nicht als inhaltsleere Einträge in der Sammlung landen.
9Reicht delete_empty aus, um die Anzahl der Einträge zu begrenzen?
Nein, delete_empty entfernt nur leere Einträge, begrenzt aber nicht die maximale Anzahl. Für eine echte Obergrenze sind zusätzlich ein PRE_SUBMIT-Listener und eine Count-Validierungsconstraint auf der Entity-Property sinnvoll.
10In welcher Reihenfolge sollte ich CollectionType-Funktionalität einführen?
Am sinnvollsten zunächst mit fester Eintragsanzahl ohne allow_add, um Mapping und Validierung zu prüfen, danach JavaScript-Interaktion für Hinzufügen und Entfernen, und zuletzt den PRE_SUBMIT-Listener als Sicherheitsnetz gegen überdimensionierte Payloads.