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
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
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
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.