Symfony Form: Custom Form Types und Daten-Transformatoren
AI generated
SF
{ }
Symfony · Forms · Custom Types · Data Transformer
Symfony Form:
Custom Types und Daten-Transformatoren

Das Symfony Form-Component ist eines der mächtigsten und komplexesten im Ökosystem. Wer nur Built-in-Types nutzt, verschenkt die Hälfte des Potenzials. Custom Form Types kapseln Wiederverwendbarkeit, Data Transformers überbrücken die Kluft zwischen Formulareingabe und Domänenobjekten — ohne Boilerplate in jedem einzelnen Formular.

18 Min. Lesezeit Custom Types · Data Transformer · Compound Types · Twig Widget · Validierung Symfony 7.x · PHP 8.3+

1. Das Symfony Form-System verstehen

Das Symfony Form-Component verarbeitet Formulardaten in drei Schichten: der View-Schicht (HTML-Eingabe des Nutzers), der Norm-Schicht (die interne Darstellung im Form-Objekt) und der Model-Schicht (das PHP-Objekt, das das Formular befüllt). Ein Custom Form Type definiert, wie Daten zwischen diesen drei Schichten transformiert werden. Ein DateType beispielsweise empfängt aus der View drei separate Eingaben (Tag, Monat, Jahr), kombiniert sie in der Norm-Schicht zu einem DateTimeInterface-Objekt und übergibt es als DateTime an das Model. Wer diesen dreischichtigen Datenfluss versteht, kann jeden Custom Form Type präzise implementieren.

Der Datenfluss beim Submit: Rohdaten aus dem Request landen in der View-Schicht. View-Transformer wandeln sie in die Norm-Darstellung um (z.B. String zu DateTime). Model-Transformer wandeln die Norm-Darstellung in das Modell-Objekt um (z.B. DateTime zu einer eigenen DateValue-Klasse). Beim Anzeigen des Formulars läuft derselbe Prozess rückwärts: Model-Objekt zu Norm, Norm zu View. Das Verständnis dieser Richtung ist entscheidend für die Implementierung von Data Transformers, weil die transform()-Methode (Modell zu View) und die reverseTransform()-Methode (View zu Modell) unterschiedliche Fehlerszenarien haben und unterschiedlich mit ungültigen Eingaben umgehen müssen.

2. Eigenen Form Type erstellen: Schritt für Schritt

Ein Custom Symfony Form Type ist eine PHP-Klasse, die AbstractType erweitert. Die Methode buildForm() definiert die Felder des Typs. configureOptions() definiert die verfügbaren Optionen mit Standardwerten und Validierungsregeln. getParent() gibt den Elterntyp an, von dem das neue Feld erbt — für einfache Wrapper-Typen ist das meist ein Built-in-Type wie TextType oder IntegerType. Für vollständig neue Typen ohne Vererbung wird FormType als Parent verwendet, was zu einem eigenständigen Form-Baum-Knoten ohne vorgeerbtes Verhalten führt.

Ein häufiges Anwendungsbeispiel ist ein MoneyType, der einen Betrag in Cent als Integer im Modell erwartet, aber dem Nutzer einen Euro-Betrag mit zwei Dezimalstellen anzeigt. Das Built-in MoneyType von Symfony hat begrenzte Konfigurierbarkeit für spezifische Geschäftsanforderungen — zum Beispiel für mehrere Währungen, die von der Datenbank geladen werden, oder für spezifische Formatierungen je nach Locale. Ein eigener MoneyInputType kapselt diese Logik einmal und ist über alle Formulare des Projekts hinweg wiederverwendbar, ohne dass jedes Formular die Transformationslogik dupliziert.


<?php

declare(strict_types=1);

namespace App\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use App\Form\Transformer\MoneyTransformer;

/**
 * Custom Form Type: renders a money amount as decimal string,
 * stores and retrieves value as integer cents in the model.
 */
final class MoneyInputType extends AbstractType
{
    public function __construct(
        private readonly MoneyTransformer $moneyTransformer,
    ) {}

    /**
     * Add the money-to-cents transformer to the form field.
     */
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        // Add model transformer: converts cents (int) ↔ decimal string (view)
        $builder->addModelTransformer($this->moneyTransformer);
    }

    /**
     * Define type options with defaults and allowed values.
     */
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'currency'       => 'EUR',
            'decimal_places' => 2,
            'attr'           => ['placeholder' => '0.00', 'inputmode' => 'decimal'],
        ]);

        $resolver->setAllowedTypes('currency', 'string');
        $resolver->setAllowedValues('decimal_places', [0, 1, 2, 3]);
    }

    /**
     * Inherit from TextType — renders as <input type="text"> with transformer applied.
     */
    public function getParent(): string
    {
        return TextType::class;
    }

    /**
     * Block prefix for Twig widget customization: form_widget(field, {custom_options}).
     */
    public function getBlockPrefix(): string
    {
        return 'money_input';
    }
}

3. Form-Options und configureOptions richtig nutzen

Der OptionsResolver in configureOptions() ist das Konfigurationssystem jedes Custom Form Types. Er validiert Optionen beim Erstellen des Formulars und gibt klare Fehlermeldungen, wenn unbekannte oder ungültige Optionen übergeben werden. Das verhindert stille Fehlkonfigurationen, die nur zur Laufzeit auffallen. Mit setRequired() werden Pflichtoptionen definiert, mit setDefault() Standardwerte, mit setAllowedTypes() wird der Typ einer Option eingeschränkt und mit setAllowedValues() ein Enum-artiger Wertebereich.

Lazy Defaults sind ein häufig unterschätztes Feature: Eine Option kann einen Standardwert bekommen, der von einer anderen Option abhängt. $resolver->setDefault('label', fn(Options $options) => ucfirst($options['currency']) . ' Amount') berechnet den Default-Label-Wert aus der currency-Option, wenn der Aufrufer keine eigene label-Option übergibt. Das verhindert duplizierte Logik: Der Custom Form Type verwaltet die Abhängigkeit zwischen Optionen intern, der Aufrufer muss nur die notwendigsten Optionen angeben. Normalisierung mit setNormalizer() transformiert übergebene Optionswerte vor der Validierung — zum Beispiel um Strings zu uppercase zu konvertieren.

4. Data Transformers: zwischen Eingabe und Domäne übersetzen

Data Transformers implementieren das DataTransformerInterface mit zwei Methoden: transform($value) wandelt den Modellwert in die View-Darstellung um (für die Anzeige des Formulars), und reverseTransform($value) wandelt die View-Darstellung in den Modellwert um (nach dem Submit). Ein Data Transformer für einen Money-Type wandelt eine Integer-Zahl in Cent (Modell) in einen formatierten Decimal-String (View) und zurück. Bei transform(null) muss immer eine sichere View-Darstellung zurückgegeben werden (leerer String), weil das Formular beim ersten Rendern möglicherweise noch keinen Wert hat.

Die Unterscheidung zwischen Model-Transformern und View-Transformern ist wichtig für komplexe Szenarien. Model-Transformer (addModelTransformer()) transformieren zwischen Modell und Norm-Darstellung. View-Transformer (addViewTransformer()) transformieren zwischen Norm und HTML-Eingabe. Für die meisten Custom Form Types reicht ein Model-Transformer. View-Transformer sind nur nötig, wenn die Norm-Darstellung selbst komplexer als ein einfacher Scalar ist — etwa beim DateType, der in der Norm-Schicht ein DateTime-Objekt hat und in der View-Schicht mehrere separate Strings für Tag, Monat und Jahr.


<?php

declare(strict_types=1);

namespace App\Form\Transformer;

use Symfony\Component\Form\DataTransformerInterface;
use Symfony\Component\Form\Exception\TransformationFailedException;

/**
 * Data Transformer: converts between integer cents (model) and decimal string (view).
 * 1234 cents → "12.34" (transform) | "12.34" → 1234 cents (reverseTransform)
 */
final class MoneyTransformer implements DataTransformerInterface
{
    public function __construct(
        private readonly int $decimalPlaces = 2,
    ) {}

    /**
     * Transform model value (integer cents) to view value (decimal string).
     * Called when rendering the form field.
     */
    public function transform(mixed $value): string
    {
        if ($value === null) {
            return ''; // Safe empty state for new form
        }

        if (!is_int($value)) {
            throw new TransformationFailedException(
                sprintf('Expected int cents, got %s.', get_debug_type($value)),
            );
        }

        // Convert cents to decimal: 1234 → 12.34
        $divisor = 10 ** $this->decimalPlaces;
        return number_format($value / $divisor, $this->decimalPlaces, '.', '');
    }

    /**
     * Reverse transform view value (decimal string) to model value (integer cents).
     * Called after form submit — throws TransformationFailedException on invalid input.
     */
    public function reverseTransform(mixed $value): ?int
    {
        if ($value === null || $value === '') {
            return null; // Allow empty — NotBlank constraint handles required validation
        }

        // Normalize locale-specific decimal separators (comma → dot)
        $normalized = str_replace(',', '.', (string) $value);

        if (!is_numeric($normalized)) {
            throw new TransformationFailedException(
                sprintf('"%s" is not a valid monetary amount.', $value),
            );
        }

        $floatValue = (float) $normalized;
        if ($floatValue < 0) {
            throw new TransformationFailedException('Monetary amount cannot be negative.');
        }

        // Convert decimal to cents: 12.34 → 1234
        $multiplier = 10 ** $this->decimalPlaces;
        return (int) round($floatValue * $multiplier);
    }
}

5. Reverse Transformer: Fehlerbehandlung bei ungültiger Eingabe

Die reverseTransform()-Methode eines Data Transformers ist für die Fehlerbehandlung bei ungültiger Benutzereingabe verantwortlich. Wenn die Eingabe nicht transformiert werden kann — eine nicht-numerische Eingabe in einem Money-Feld, eine unbekannte Entity-ID in einem EntityType — muss der Transformer eine TransformationFailedException werfen. Symfony fängt diese Exception und fügt einen Validierungsfehler zum Formularfeld hinzu, ohne dass der Entwickler im Formular selbst Exception-Handling implementieren muss. Der Fehlertext aus der Exception erscheint im Formular als Validierungsmeldung.

Ein subtiles Problem: Die TransformationFailedException löst eine INVALID-Meldung aus, die standardmäßig den internen PHP-Fehlertext anzeigt — nicht benutzerfreundlich. Um eine eigene, lokalisierte Fehlermeldung anzuzeigen, wird in configureOptions() die Option invalid_message gesetzt: 'invalid_message' => 'Bitte gib einen gültigen Betrag ein.'. Symfony ersetzt den internen Transformer-Fehler durch diese benutzerfreundliche Meldung. Platzhalter wie { { value } } geben den ungültigen Eingabewert in der Fehlermeldung aus und helfen dem Nutzer, seinen Fehler nachzuvollziehen.

6. Compound Form Types: mehrere Felder in einem Type

Compound Custom Form Types sind Types, die aus mehreren Unterfeldern bestehen. Ein AddressType beispielsweise enthält Felder für Straße, Hausnummer, PLZ und Stadt — aber aus Sicht des aufrufenden Formulars ist es ein einziges Feld. Der Compound Type baut die Unterfelder in buildForm() mit $builder->add() auf, und Symfony verwaltet den Datenfluss zu den Unterfeldern automatisch. Das aufrufende Formular sieht nur ->add('address', AddressType::class) — die Unterfelder sind vollständig gekapselt.

Ein Data Mapper ist die Verbindung zwischen dem Compound Type und dem Domänenobjekt. Standardmäßig nutzt Symfony Property Access: Felder mit dem Namen street werden auf $address->street gemappt. Wenn das Domänenobjekt eine andere Struktur hat — zum Beispiel ein Value Object mit Factory-Methode statt öffentlicher Properties — implementiert man einen eigenen Data Mapper, der DataMapperInterface implementiert. Die mapDataToForms()-Methode füllt die Unterfelder aus dem Domänenobjekt, mapFormsToData() erstellt aus den Unterfeld-Werten nach dem Submit ein neues Domänenobjekt.


<?php

declare(strict_types=1);

namespace App\Form\Type;

use App\Form\DataMapper\AddressDataMapper;
use App\ValueObject\Address;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Compound Form Type for postal address — wraps 4 fields into a single reusable type.
 * Uses a custom Data Mapper to create an immutable Address Value Object on submit.
 */
final class AddressType extends AbstractType
{
    public function __construct(
        private readonly AddressDataMapper $mapper,
    ) {}

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        // All four address sub-fields defined once — reused across all address forms
        $builder
            ->add('street', TextType::class, [
                'label'       => 'Straße',
                'constraints' => [new Assert\NotBlank(), new Assert\Length(max: 100)],
            ])
            ->add('houseNumber', TextType::class, [
                'label'       => 'Hausnummer',
                'constraints' => [new Assert\NotBlank(), new Assert\Length(max: 10)],
            ])
            ->add('postalCode', TextType::class, [
                'label'       => 'PLZ',
                'constraints' => [new Assert\NotBlank(), new Assert\Regex('/^\d{5}$/')],
            ])
            ->add('city', TextType::class, [
                'label'       => 'Stadt',
                'constraints' => [new Assert\NotBlank(), new Assert\Length(max: 100)],
            ]);

        // Custom Data Mapper: maps Address Value Object ↔ form fields
        $builder->setDataMapper($this->mapper);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class'        => Address::class,
            'empty_data'        => null, // Mapper handles empty state
            'label'             => false, // Compound type usually has no outer label
        ]);
    }
}

// Usage in a parent form — AddressType appears as a single compound field:
// $builder->add('deliveryAddress', AddressType::class, ['label' => 'Lieferadresse']);
// $builder->add('billingAddress', AddressType::class, ['label' => 'Rechnungsadresse']);

7. Eigenes Twig-Widget für Custom Form Types

Jeder Custom Form Type kann mit einem eigenen Twig-Widget gerendert werden. Das Widget definiert das HTML, das Symfony beim Rendern des Feldes via { { form_widget(field) } } ausgibt. Ohne eigenes Widget erbt der Custom Type das Rendering seines Parent-Types. Mit einem eigenen Widget kann das HTML beliebig gestaltet werden — für spezielle Input-Komponenten, für Currency-Symbole die visuell vor oder nach dem Inputfeld angezeigt werden, oder für komplexe Compound-Types, die eine spezifische Layout-Struktur brauchen.

Das Widget wird in einer Twig-Datei als Block mit dem Naming-Pattern blockprefix_widget definiert. Der Block-Präfix wird in getBlockPrefix() des Custom Form Types festgelegt. Die Twig-Datei wird in twig.yaml als Form-Theme registriert: entweder global für alle Formulare oder lokal pro Template mit {% form_theme form 'form/money_input.html.twig' %}. Im Widget-Block stehen alle Symfony-Form-Variablen zur Verfügung: id, name, value, required, attr und alle Custom-Optionen, die via vars an die View übergeben wurden.

8. Validierung in Custom Form Types integrieren

Validierung in Custom Form Types erfolgt auf zwei Ebenen: Constraint-basiert auf dem Datenobjekt (via Symfony Validator) und Transformer-basiert im Data Transformer (via TransformationFailedException). Transformer-Fehler prüfen die syntaktische Korrektheit der Eingabe (ist es ein gültiger Geldwert überhaupt?), Constraints prüfen die semantische Korrektheit (ist der Betrag größer als 0? Ist er kleiner als das verfügbare Budget?). Die Trennung ist wichtig: Transformer-Fehler verhindern, dass ungültige Daten in das Domänenobjekt gelangen. Constraints prüfen Domänenregeln, die voraussetzen, dass die Daten bereits korrekt deserialisiert wurden.

Default-Constraints für einen Custom Form Type werden in configureOptions() mit 'constraints' => [new Assert\NotBlank()] als Default-Wert gesetzt. Diese Constraints gelten für alle Instanzen des Types, sofern sie nicht vom Aufrufer überschrieben werden. Eigene Constraints, die für den Type spezifische Regeln abprüfen — zum Beispiel, dass ein Geldwert nicht größer als ein Maximum ist, das als Option übergeben wurde — implementiert man als Constraint-Klasse plus zugehörigen Validator. Der Validator empfängt den bereits transformierten Modellwert und prüft ihn gegen die Options-Konfiguration des Types.

9. Custom Type vs. Form Events vs. Data Mapper

Symfony Forms bieten mehrere Mechanismen für komplexe Szenarien: Custom Form Types, Form Events und Data Mapper. Die Wahl des richtigen Ansatzes hängt davon ab, was angepasst werden soll. Custom Form Types sind für Wiederverwendung: Wenn dasselbe Feld-Verhalten in mehreren Formularen vorkommt, kapselt es ein Custom Type. Form Events (PRE_SET_DATA, POST_SUBMIT) sind für dynamisches Formularverhalten: Felder hinzufügen oder entfernen basierend auf Datenwerten. Data Mapper sind für komplexe Zuordnung zwischen Formularfeldern und Domänenobjekten, die nicht dem einfachen Property-Access-Muster folgen.

Ansatz Bestes Einsatzgebiet Wiederverwendung Komplexität
Custom Form Type Wiederkehrende Feldtypen, Datentransformation Hoch — einmal bauen, überall nutzen Mittel
Data Transformer Eingabe ↔ Domänenobjekt übersetzen Hoch — als Service injizierbar Mittel
Form Events Dynamische Felder, konditionale Logik Gering — spezifisch für ein Formular Hoch
Data Mapper Value Objects, Factory-Methoden Mittel — spezifisch für ein Domänenobjekt Hoch
Compound Type Mehrere Felder als ein wiederverwendbarer Block Sehr hoch — AddressType überall Mittel

In der Praxis werden diese Ansätze kombiniert: Ein Compound Custom Form Type mit eigenem Data Mapper für Value Objects, ein Data Transformer für spezifische Eingabeformate, und Form Events für das dynamische Hinzufügen von Feldern basierend auf der bereits eingegebenen Auswahl. Die Kombination aller drei macht komplexe Formulare in Symfony vollständig kontrollierbar, ohne Logik in Controller oder Twig zu verlagern.

Mironsoft

Symfony-Formular-Architektur, Custom Types und Domain-Integration

Komplexe Symfony-Formulare professionell implementieren?

Wir entwickeln skalierbare Symfony-Form-Architekturen mit Custom Types, Data Transformers und eigenen Twig-Widgets — von der ersten Form-Analyse bis zur produktionsreifen Implementierung.

Custom Form Types

Eigene Feldtypen für Domänenobjekte — Money, Date, Address und projektspezifische Types

Data Transformer

Datentransformation zwischen Formular und Domäne mit sauberer Fehlerbehandlung

Form-Architektur

Compound Types, Data Mapper und Form-Events für komplexe domänengetriebene Formulare

10. Zusammenfassung

Custom Form Types und Data Transformers sind die Bausteine, die Symfony-Formulare von einfachen CRUD-Masken zu ausdrucksstarken, domänengerechten Interfaces machen. Ein Custom Form Type kapselt wiederverwendbares Feldverhalten: Konfiguration, Validierung, Transformation und Rendering an einem Ort. Data Transformers überbrücken die Kluft zwischen Benutzereingabe und Domänenobjekten ohne Boilerplate in jedem Formular. Compound Types fassen mehrere Felder zu einem wiederverwendbaren Block zusammen. Data Mapper verbinden Compound Types mit Value Objects, die keine einfache Property-Access-Struktur haben.

Das Zusammenspiel aller Komponenten ergibt Formulare, die genau die Domänensprache sprechen: Ein AddressType liefert ein unveränderliches Address-Value-Object, ein MoneyInputType liefert Integer-Cents — ohne Transformationslogik im Controller und ohne Mapping-Code in jedem einzelnen Formular. Das Ergebnis ist ein wiederverwendbares, testbares und klar verständliches Formular-System, das mit wachsender Projektgröße an Wert gewinnt, statt zur Boilerplate-Sammlung zu werden.

Symfony Custom Form Types und Data Transformers — Das Wichtigste auf einen Blick

Custom Form Type

Erweitert AbstractType. buildForm() definiert Felder, configureOptions() definiert Optionen, getParent() gibt den Elterntyp an. Einmal gebaut, überall wiederverwendbar.

Data Transformer

transform(): Modell → View. reverseTransform(): View → Modell. TransformationFailedException bei ungültiger Eingabe — Symfony zeigt den Fehler im Formular an.

Compound Type

Mehrere Unterfelder in einem wiederverwendbaren Type. Data Mapper für Value Objects. Aufrufendes Formular sieht nur einen ->add('address', AddressType::class).

Twig Widget

Block blockprefix_widget in einer Form-Theme-Datei. Als Form-Theme registrieren. Vollständige HTML-Kontrolle über das Rendering jedes Custom Types.

11. FAQ: Symfony Custom Form Types und Data Transformers

1Was ist ein Custom Form Type?
PHP-Klasse, die AbstractType erweitert. Kapselt Feldstruktur, Optionen, Validierung und Transformation. Einmal gebaut, überall wiederverwendbar.
2Was ist ein Data Transformer?
transform(): Modell → View. reverseTransform(): View → Modell. TransformationFailedException bei ungültiger Eingabe zeigt Fehler im Formular an.
3Model-Transformer vs. View-Transformer?
Model: Modell ↔ Norm. View: Norm ↔ HTML-Eingabe. Für die meisten Custom Types reicht ein Model-Transformer.
4Was ist ein Compound Form Type?
Fasst mehrere Unterfelder zu einem wiederverwendbaren Type zusammen. AddressType = Straße + Hausnummer + PLZ + Stadt in einem einzigen add().
5Was macht configureOptions()?
Definiert Optionen mit Defaults, Typen und erlaubten Werten via OptionsResolver. Fehlkonfiguration wird beim Erstellen sofort gemeldet, nicht still ignoriert.
6Ungültige Eingaben in reverseTransform()?
TransformationFailedException werfen. Symfony zeigt den Fehler im Formular. Benutzerfreundliche Meldung via invalid_message-Option in configureOptions().
7Was ist ein Data Mapper?
Verbindet Compound Type mit Value Objects. mapDataToForms() füllt Felder. mapFormsToData() erstellt das Objekt aus Feldern — für Factory-Methoden statt Properties.
8Twig-Widget registrieren?
Block blockprefix_widget in Twig-Datei. In twig.yaml als Form-Theme registrieren oder per {% form_theme %} im Template.
9Custom Form Types testen?
TypeTestCase von Symfony: Type instanziieren, submit() mit Test-Daten aufrufen. Transformer separat testen: transform() und reverseTransform() direkt aufrufen.
10Custom Type vs. Form Events?
Form Events für dynamisches Hinzufügen/Entfernen von Feldern. Custom Types für Wiederverwendung. Wenn das Feld überall gleich funktionieren soll — Custom Type wählen.