The CollectionType field is the standard way of editing a list of items in a Symfony form, but it has two limitations: all its entries must use the same form type, and submitted rows are matched to entries by their position in the collection. Symfony 8.2 removes both of them.

Collections with Different Entry Types

Michael Käfer
Contributed by Michael Käfer in #63487

Consider a CMS page built from a list of blocks, where each block can be a heading, a paragraph or a footer. Each kind of block needs its own form type, so a single entry_type option is not enough. The new PolymorphicCollectionType field lets you declare one form type per kind of entry, and a provider that picks one of them for each entry:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
use App\Form\Block\BlockEntryTypeProvider;
use App\Form\Block\FooterType;
use App\Form\Block\HeadingType;
use App\Form\Block\ParagraphType;
use Symfony\Component\Form\Extension\Core\Type\PolymorphicCollectionType;
// ...

$builder->add('blocks', PolymorphicCollectionType::class, [
    'entry_types' => [
        'heading' => HeadingType::class,
        'paragraph' => ParagraphType::class,
        'footer' => FooterType::class,
    ],
    'entry_type_provider' => new BlockEntryTypeProvider(),
    'allow_add' => true,
    'allow_delete' => true,
]);

When allow_add is enabled, Symfony builds one prototype per entry type and renders them as data-prototype-<name> HTML attributes (data-prototype-heading, data-prototype-paragraph, etc.), so your JavaScript code can pick the one to insert. The entry_options, prototype_options and prototype_data options work as in CollectionType, but their values are indexed by entry name.

Choosing the Type of Each Entry

Michael Käfer
Contributed by Michael Käfer in #63487

The entry_type_provider option decides which form type each entry uses. The provider receives the data of an entry in two different shapes, so it defines two methods:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace App\Form\Block;

use App\Entity\Footer;
use App\Entity\Heading;
use Symfony\Component\Form\EntryTypeProviderInterface;

class BlockEntryTypeProvider implements EntryTypeProviderInterface
{
    public function forModelData(mixed $data): int|string
    {
        return match (true) {
            $data instanceof Heading => 'heading',
            $data instanceof Footer => 'footer',
            default => 'paragraph',
        };
    }

    public function forSubmittedData(mixed $data): int|string
    {
        return $data['type'] ?? 'paragraph';
    }
}

The forSubmittedData() method is only needed for entries added in the browser. Their submitted data only contains the values of their fields, so each entry type in this example adds a hidden and unmapped type field with its entry name.

Naming Entries After Their Data

Nicolas Grekas
Contributed by Nicolas Grekas in #66217

The forms of a collection are named after the keys of its entries. If the order of the entries changes between rendering the form and submitting it (for example, because another user edited the same data), the values submitted for row 0 are written to whatever entry is now at key 0.

The new entry_name option, available in both CollectionType and PolymorphicCollectionType, names each form after the entry itself. Its value can be a property path that is read from each entry:

1
2
3
4
5
6
7
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
// ...

$builder->add('tags', CollectionType::class, [
    'entry_type' => TagType::class,
    'entry_name' => 'id',
]);

It can also be a callable that receives the entry and its key, which is useful when some entries don't have an ID yet:

1
2
3
4
$builder->add('tags', CollectionType::class, [
    'entry_type' => TagType::class,
    'entry_name' => fn (Tag $tag, int $key) => $tag->getId() ?? 'new_'.$key,
]);

This option only changes how the form identifies each entry, not your data. If the tag with ID 42 is stored at key 0, its fields are now named post[tags][42][name] instead of post[tags][0][name], but the submitted values are still written to that tag and the collection keeps its original keys.

Caution

When using allow_add, a new row whose name matches an existing entry updates that entry. A numeric counter can match the ID of an entry, so use names that entries can't have, such as new_1.

Published in #Living on the edge