Embedded Field in Entity und der FormType

Custom FormType mit Doctrine-Embedded Value Object

Anleitung am Beispiel von StringField / StringFieldType.

Der Custom FormType kapselt ein Doctrine-Embedded Value Object mit eigenem Layout (Form-Theme), sodass im Zieltemplate nur noch {{ form_row(form.fieldName) }} steht.

Überblick

Drei Bausteine gehören zusammen:

  1. Value Object als #[ORM\Embeddable] mit Properties und passenden Accessoren
  2. FormType, der die Subfelder aufbaut und als data_class das Value Object setzt
  3. Form-Theme mit *_row / *_widget Blocks, global registriert

Im aufnehmenden Entity wird das Value Object via #[ORM\Embedded(class: …)] eingebettet und im Konstruktor initialisiert.

1. Value Object (Embeddable)

// src/Entity/ValueObject/StringField.php
namespace App\Entity\ValueObject;

use Doctrine\ORM\Mapping as ORM;

#[ORM\Embeddable]
class StringField
{
    #[ORM\Column(type: 'boolean')]
    private bool $visibility = true;

    #[ORM\Column(type: 'boolean')]
    private bool $canEdit = true;

    #[ORM\Column(type: 'string', length: 255)]
    private string $value = '';

    public function __construct(bool $visibility = true, bool $canEdit = true, string $value = '')
    {
        $this->visibility = $visibility;
        $this->canEdit = $canEdit;
        $this->value = $value;
    }

    // Wichtig: Symfony PropertyAccess benötigt passende Accessoren
    // zum Property-Namen. Property `visibility` → getVisibility()/setVisibility().
    public function getVisibility(): bool { return $this->visibility; }
    public function setVisibility(bool $v): void { $this->visibility = $v; }

    public function getCanEdit(): bool { return $this->canEdit; }
    public function setCanEdit(bool $v): void { $this->canEdit = $v; }

    public function getValue(): string { return $this->value; }
    public function setValue(string $v): void { $this->value = $v; }
}

Fallstricke:

  • Properties nicht nullable, wenn die Setter string/bool erwarten — sonst Fehler beim Binden leerer Werte (→ siehe empty_data im FormType).
  • Accessor-Namen müssen zum Property-Namen passen. isVisible() reicht nicht für Property visibility — Symfony sucht getVisibility/isVisibility/hasVisibility.
  • #[ORM\Embeddable] (keine Entity-Annotation, keine ID).

2. Embedded-Field im Entity

// src/Entity/AnimalCard.php
use App\Entity\ValueObject\StringField;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class AnimalCard
{
    #[ORM\Embedded(class: StringField::class)]
    private StringField $animalType;

    public function __construct()
    {
        $this->animalType = new StringField(); // zwingend, sonst null bei neuen Entities
    }

    public function getAnimalType(): StringField { return $this->animalType; }
    public function setAnimalType(StringField $s): void { $this->animalType = $s; }
}

Fallstricke:

  • Das Embedded-Property muss im Konstruktor initialisiert werden, sonst schlägt das Formular beim Rendern eines neuen Entities fehl.
  • Doctrine legt pro Property eine Spalte mit Prefix an: animal_type_visibility, animal_type_can_edit, animal_type_value. Migration erzeugen via ddev exec php bin/console make:migration.

3. FormType

// src/Form/FieldType/StringFieldType.php
namespace App\Form\FieldType;

use App\Entity\ValueObject\StringField;
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 StringFieldType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('value', TextType::class, [
                'label' => $options['value_label'],
                'required' => false,
                'help' => $options['value_help'],
                'attr' => $options['value_attr'],
                'empty_data' => '', // leere Eingabe → '' statt null
            ])
            ->add('visibility', CheckboxType::class, [
                'label' => false,
                'required' => false,
                'attr' => ['title' => 'Öffentlich sichtbar …'],
            ])
            ->add('canEdit', CheckboxType::class, [
                'label' => false,
                'required' => false,
                'attr' => ['title' => 'Bearbeitbar …'],
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => StringField::class,
            'value_label' => 'Wert',
            'value_help' => null,
            'value_attr' => [],
        ]);
        $resolver->setAllowedTypes('value_label', ['string', 'bool']);
        $resolver->setAllowedTypes('value_help', ['string', 'null']);
        $resolver->setAllowedTypes('value_attr', 'array');
    }
}

Fallstricke:

  • data_class auf das Value Object setzen, damit Symfony ein Objekt hydratisiert und nicht ein Array.
  • empty_data => '' auf Textfeldern, deren Setter string (nicht ?string) erwartet.
  • Eigene Optionen (value_label, value_help, …) immer via configureOptions() registrieren und Typen einschränken.

Block-Prefix

Symfony leitet den Block-Prefix automatisch aus dem Klassennamen ab:
StringFieldTypestring_field. Das ist der Name für die Theme-Blocks. Bei Bedarf überschreibbar via getBlockPrefix().

4. Form-Theme

{# templates/form/string_field_theme.html.twig #}
{% block string_field_row %}
    <div class="mb-3">
        {% if label is not same as(false) and label %}
            <label for="{{ form.value.vars.id }}" class="form-label">{{ label }}</label>
        {% endif %}
        {{ block('string_field_widget') }}
        {% if form.value.vars.help %}
            <div class="form-text">{{ form.value.vars.help }}</div>
        {% endif %}
        {{ form_errors(form) }}
    </div>
{% endblock %}

{% block string_field_widget %}
    <div class="input-group">
        {{ form_widget(form.value) }}
        <span class="input-group-text" title="{{ form.visibility.vars.attr.title }}">
            {{ form_widget(form.visibility) }}
        </span>
        <span class="input-group-text" title="{{ form.canEdit.vars.attr.title }}">
            {{ form_widget(form.canEdit) }}
        </span>
    </div>
{% endblock %}

Globale Registrierung in config/packages/twig.yaml:

twig:
    form_themes:
        - 'form/bootstrap_5_layout.html.twig'
        - 'form/string_field_theme.html.twig'

Block-Konventionen:

  • {block_prefix}_row — komplette Zeile (Label + Widget + Help + Errors)
  • {block_prefix}_widget — nur das Widget (ohne Rahmen)
  • {block_prefix}_label — nur das Label
  • {block_prefix}_errors — nur Fehleranzeige

5. Nutzung im übergeordneten FormType

// src/Form/AnimalCardType.php
$builder->add('animalType', StringFieldType::class, [
    'label' => 'Tierart',
    'value_label' => 'Tierart',
    'value_help' => 'z.B. Hund, Katze, Kaninchen',
    'value_attr' => ['placeholder' => 'z.B. Hund'],
]);

6. Rendering im Template

{{ form_row(form.animalType) }}

Das reicht — das Form-Theme übernimmt Label, Layout, Help-Text und Fehler.

Checkliste neuer Field-Type

  1. Value Object unter src/Entity/ValueObject/ mit #[ORM\Embeddable] und passenden Accessoren anlegen
  2. FormType unter src/Form/FieldType/ mit data_class auf das Value Object
  3. empty_data für non-nullable String-/Bool-Felder setzen
  4. Custom Options via configureOptions() + setAllowedTypes()
  5. Form-Theme unter templates/form/ anlegen ({prefix}_row + {prefix}_widget)
  6. Theme in config/packages/twig.yaml eintragen
  7. Im Ziel-Entity das Property via #[ORM\Embedded] einbinden und im Konstruktor initialisieren
  8. Migration: ddev exec php bin/console make:migration → prüfen, dann doctrine:migrations:migrate
  9. Im übergeordneten FormType via ->add('propertyName', DeinFieldType::class, […]) nutzen
  10. Im Template {{ form_row(form.propertyName) }} genügt

Zurück