Collections and Dynamic Forms

Add and remove rows with CollectionType and its prototype, and reshape a form from its own event listener.

Syntax$builder->add('tags', CollectionType::class, ['entry_type' => TagType::class, 'allow_add' => true]);

Some fields are a list of rows: tags on an article, lines on an invoice, opening hours on a shop. CollectionType renders one sub-form per item in a collection, and can let the browser add and remove rows.

The options that do the work

entry_type is the form type used for each row, and entry_options is passed to every one of them. allow_add accepts rows the server never rendered. allow_delete removes rows that come back missing. by_reference, true by default, means the form modifies the existing collection object in place and never calls the setter; set it to false when the owning class exposes adder and remover methods that must run. keep_as_list renumbers the surviving rows after a removal so the indexes have no gaps.

The prototype

With allow_add, the form exposes a prototype: one blank row of HTML whose field names contain the placeholder __name__. Print it into a data attribute, and JavaScript clones it and replaces __name__ with the next index. Symfony ships no JavaScript for this; about ten lines do it.

Changing the form itself

A form type can listen to its own events. FormEvents::PRE_SET_DATA fires before the data is set, so a listener can add or drop fields based on the object it is given - a field only for new records, a field only for one status. FormEvents::PRE_SUBMIT sees the raw submitted array before any conversion. In POST_SUBMIT the form that fired the event cannot be modified, which is why a listener that reacts to one field's value is attached to that child and reaches its parent with $event->getForm()->getParent().

Example

Example · php
<?php
// src/Form/ArticleType.php

declare(strict_types=1);

namespace App\Form;

use App\Entity\Article;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class ArticleType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add('title', TextType::class)
            ->add('tags', CollectionType::class, [
                // One TagType sub-form per item in the collection.
                'entry_type' => TagType::class,
                'entry_options' => ['label' => false],
                // Accept rows the server did not render.
                'allow_add' => true,
                // Remove rows that come back missing.
                'allow_delete' => true,
                // Call addTag()/removeTag() instead of mutating in place.
                'by_reference' => false,
                // Renumber remaining rows so indexes have no gaps.
                'keep_as_list' => true,
            ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => Article::class]);
    }
}

When to use it

  • Let an editor add and remove tag rows on an article without reloading the page.
  • Edit the lines of an invoice as one form, deleting a line by removing its row.
  • Show a "retired" checkbox only on rows that already exist in the database.
  • Keep an entity's adder and remover methods in charge of both sides of a relation.

More examples

The prototype and the ten lines of JavaScript

The prototype is ordinary rendered HTML held in an attribute, which is why it must be escaped with e('html_attr'). The script only clones it and renumbers the placeholder; the server does the rest, because allow_add tells the form to accept indexes it never sent. The loop here is over form views, not entities, so it issues no queries. Reach for the explicit loop whenever the row needs your own markup or a delete button.

Example · twig
{# templates/article/_form.html.twig #}
{{ form_start(form) }}
    {{ form_row(form.title) }}

    {# data-index is where the script carries on numbering.
       data-prototype holds the blank row, with __name__ placeholders. #}
    <ul class="tag-rows"
        data-index="{{ form.tags|length > 0 ? form.tags|last.vars.name + 1 : 0 }}"
        data-prototype="{{ form_widget(form.tags.vars.prototype)|e('html_attr') }}">

        {# Longhand. {{ form_row(form.tags) }} renders the same rows,
           but then the <li> wrappers are the theme's, not yours. #}
        {% for tag in form.tags %}
            <li>{{ form_row(tag) }}</li>
        {% endfor %}
    </ul>

    <button type="button" class="add-tag">Add a tag</button>
    <button type="submit">Save</button>
{{ form_end(form) }}

<script>
document.querySelector('.add-tag').addEventListener('click', () => {
    const holder = document.querySelector('.tag-rows');
    const row = document.createElement('li');
    // Every __name__ becomes the next index, so the names are unique.
    row.innerHTML = holder.dataset.prototype.replace(/__name__/g, holder.dataset.index);
    holder.appendChild(row);
    holder.dataset.index++;
});
</script>

Adders and removers on the owning entity

These two methods are what by_reference => false makes the form call. They are also where the other side of the relation is kept in step, which a collection the form mutates directly would skip. orphanRemoval means a tag taken out of the collection is deleted rather than orphaned with a null foreign key, so a removed row disappears on flush. Any collection edited through a form wants this pair.

Example · php
<?php
// src/Entity/Article.php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Article
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    /** @var Collection<int, Tag> */
    #[ORM\OneToMany(targetEntity: Tag::class, mappedBy: 'article', cascade: ['persist'], orphanRemoval: true)]
    private Collection $tags;

    public function __construct()
    {
        $this->tags = new ArrayCollection();
    }

    /** @return Collection<int, Tag> */
    public function getTags(): Collection
    {
        return $this->tags;
    }

    public function addTag(Tag $tag): void
    {
        if (!$this->tags->contains($tag)) {
            $this->tags->add($tag);
            // The owning side is Tag, so set it or the row has no article_id.
            $tag->setArticle($this);
        }
    }

    public function removeTag(Tag $tag): void
    {
        if ($this->tags->removeElement($tag)) {
            $tag->setArticle(null);
        }
    }
}

A field that only exists sometimes

PRE_SET_DATA runs once per sub-form, before the data is attached, so each row in a collection decides its own fields from its own object. getData() can be null here, on a brand-new row, which is why the instanceof check comes first. Use this whenever a field makes sense only for some records; the alternative, rendering it always and ignoring it later, accepts input you did not mean to accept.

Example · php
<?php
// src/Form/TagType.php

declare(strict_types=1);

namespace App\Form;

use App\Entity\Tag;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Event\PreSetDataEvent;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\OptionsResolver\OptionsResolver;

final class TagType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('name', TextType::class);

        $builder->addEventListener(
            FormEvents::PRE_SET_DATA,
            static function (PreSetDataEvent $event): void {
                $tag = $event->getData();
                $form = $event->getForm();

                // Only a saved tag can be retired, so only it gets the field.
                if ($tag instanceof Tag && null !== $tag->getId()) {
                    $form->add('retired', CheckboxType::class, ['required' => false]);
                }
            },
        );
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults(['data_class' => Tag::class]);
    }
}

Discussion

  • Be the first to comment on this lesson.