Dynamic Forms
Once a form needs to manage a variable number of similar sub-forms, for example line items on an order or tags on an article, a single field is no longer enough. CollectionType solves exactly this problem, but it requires allow_add/allow_delete, correct entity mapping, prototype-based JavaScript, and targeted PRE_SET_DATA and PRE_SUBMIT event listeners working together to be robust and secure.
Table of Contents
- 1. When a fixed form structure is no longer enough
- 2. CollectionType basic configuration: entry_type, allow_add, allow_delete
- 3. by_reference: false and adder/remover methods on the entity
- 4. The prototype attribute and JavaScript integration without a page reload
- 5. Removing entries in the browser without reloading the page
- 6. PRE_SET_DATA: adding an initial empty row for new entities
- 7. PRE_SUBMIT: protecting against too many submitted entries
- 8. delete_empty and validating the collection
- 9. Practical tips and summary
- 10. Summary
- 11. FAQ
1. When a fixed form structure is no longer enough
A classic Symfony form maps a fixed set of fields known at development time, which is entirely sufficient for most use cases. Once a user needs to decide for themselves how many entries of a certain type to add, though, such as an arbitrary number of line items on an order, several contacts for a company, or a variable number of tags for an article, that fixed structure hits its limits, because the number of fields is only known at runtime and can even keep changing while the form is being edited.
The Form component solves this problem through CollectionType, a field type that internally manages a collection of similar sub-forms and automatically builds one sub-form of type entry_type for every element in the underlying data collection, for example a Doctrine collection property. CollectionType handles both rendering every sub-form and mapping submitted data back onto the collection, as long as entity mapping and JavaScript work together correctly, which in practice is the more involved part of the implementation.
2. CollectionType basic configuration: entry_type, allow_add, allow_delete
The most important option is entry_type, which sets the form type of every single entry, for example OrderItemType for each line item of an order, while entry_options passes additional options to every instance of that sub-form, for example label: false, to control labels for each entry individually in the template instead of having them generated automatically. Without further configuration, CollectionType simply maps a fixed number of sub-forms, exactly matching the number of elements in the underlying collection at render time, without letting users add further entries in the browser.
allow_add lets more entries be submitted than were originally rendered in the form, while allow_delete allows the opposite: submitting fewer entries than originally present, with the missing entries treated as removed when mapped onto the entity collection. Both options only affect server-side processing of submitted data and do not themselves generate any buttons or JavaScript in the frontend, which is a common misunderstanding the first time around.
<?php
// src/Form/OrderType.php
declare(strict_types=1);
namespace App\Form;
use App\Entity\Order;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
final class OrderType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('items', CollectionType::class, [
'entry_type' => OrderItemType::class,
'entry_options' => ['label' => false],
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true,
'prototype_name' => '__item_index__',
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults(['data_class' => Order::class]);
}
}
3. by_reference: false and adder/remover methods on the entity
By default, the Form component handles collections through by_reference: true, which means the PropertyAccessor simply manipulates the returned collection object directly, without calling an explicit setter. For CollectionType with allow_add or allow_delete this is problematic, because adding or removing individual entries then bypasses the entity, especially when the inverse side of a bidirectional Doctrine relationship, for example setting the Order reference on every OrderItem, is meant to happen through a dedicated adder method like addItem().
With by_reference: false, the PropertyAccessor instead calls addItem() for every new entry and removeItem() for every removed entry on the Order entity, provided both methods exist following the usual naming convention. It is precisely inside these adder and remover methods that side effects such as setting the inverse side of the relationship can be modeled correctly, which simply does not work reliably without by_reference: false, because Doctrine otherwise only gets one side of the relationship updated and returns inconsistent data the next time the entity is loaded.
4. The prototype attribute and JavaScript integration without a page reload
When prototype is enabled, which it is by default, Twig renders a data-prototype attribute on the CollectionType field's enclosing element, containing a complete but disabled sub-form with a placeholder such as __item_index__ instead of a real index. A Stimulus controller or plain vanilla JavaScript reads this attribute, replaces the placeholder with a genuinely unique, previously unused index, and inserts the resulting HTML fragment as a new row into the DOM, entirely without a server round-trip or a page reload.
It matters to keep the index strictly unique on every addition, because duplicate indexes can cause unpredictable behavior on submit. A simple, incrementing counter, initialized on page load with the current number of already existing entries, is usually sufficient for this and more robust than, say, a random value, because the order of entries stays stable and traceable when the form gets re-rendered after a failed validation attempt.
// assets/controllers/collection_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['list', 'template'];
connect() {
this.index = this.listTarget.children.length;
}
addItem() {
const html = this.templateTarget
.dataset
.prototype
.replace(/__item_index__/g, this.index);
this.listTarget.insertAdjacentHTML('beforeend', html);
this.index++;
}
removeItem(event) {
event.target.closest('[data-collection-target="entry"]').remove();
}
}
5. Removing entries in the browser without reloading the page
Removing an entry in the browser is noticeably simpler on the client side than adding one, because it only requires removing the DOM node of the relevant row, without generating a new prototype block or computing an index. What matters is that every delete button sits directly inside the enclosing element of its row, or references which row to remove through a data attribute, so the click handler reliably hits the right node, even if further rows have been added or removed in the meantime.
On the server side, nothing special has to be done for removed entries as long as allow_delete is enabled and by_reference is set to false: since the removed entry simply no longer appears in the submitted data array, CollectionType automatically recognizes that this entry should be removed from the collection and calls the matching remover method on the entity. A common mistake is additionally building a hidden delete flag per row, which is unnecessary with correctly configured allow_delete and only needlessly complicates the form logic.
6. PRE_SET_DATA: adding an initial empty row for new entities
For an already existing order with saved line items, CollectionType automatically renders a sub-form for every existing item, because the underlying data collection already contains entries at render time. For a new, still-empty order, on the other hand, the collection is empty, so without further action not a single sub-form gets rendered and the user would first have to actively click Add before even a first line item becomes visible, which is usually poor for user guidance.
A FormEvents::PRE_SET_DATA listener on the parent FormType solves this problem by checking, before the form data is actually set, whether the collection is empty, and in that case programmatically inserting an empty OrderItem object into the collection, so that a first, empty row becomes visible automatically on render. This approach is more robust than a pure JavaScript solution, because it already provides a visible row on the very first server render even without JavaScript enabled, which matters especially for accessibility and progressive enhancement.
<?php
// src/Form/OrderType.php (excerpt)
declare(strict_types=1);
use App\Entity\OrderItem;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
$order = $event->getData();
if ($order !== null && $order->getItems()->isEmpty()) {
$order->addItem(new OrderItem());
}
});
7. PRE_SUBMIT: protecting against too many submitted entries
allow_add carries an often-overlooked security risk: because CollectionType in principle accepts more entries being submitted than were originally rendered, an attacker who manually tampers with the form payload can submit an array with tens of thousands of entries, causing Symfony to attempt building a complete sub-form with validation for every single one, which can produce noticeable server load or, in the extreme case, a denial of service.
A FormEvents::PRE_SUBMIT listener steps in at exactly the right point, because it has access to the raw, not-yet-processed submitted data as an associative array, before the Form component actually builds sub-forms from it. The listener can check whether the array for the items field exceeds a configured upper bound, and in that case truncate the excess entries before further processing, so that never more sub-forms get built than the application should reasonably allow from a business perspective.
<?php
// src/Form/OrderType.php (excerpt)
declare(strict_types=1);
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
private const MAX_ITEMS = 50;
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void {
$data = $event->getData();
if (isset($data['items']) && \count($data['items']) > self::MAX_ITEMS) {
$data['items'] = \array_slice($data['items'], 0, self::MAX_ITEMS, true);
$event->setData($data);
}
});
8. delete_empty and validating the collection
The delete_empty option automatically removes every entry from the collection whose sub-form data, after processing, matches the empty default value of entry_type, which is useful when users leave a row empty instead of explicitly removing it via the delete button. Without delete_empty, such an empty row would end up as a fully-fledged but content-free entry in the collection and would then have to be caught through validation constraints or manual cleanup in the controller.
For the total number of entries, it is additionally worth adding a Symfony Validator constraint directly on the entity's collection property, for example Count with min and max, so the business rule for how many line items an order may have at minimum or maximum applies independently of the form and is enforced consistently even when the entity is created directly outside the form, for example via an API or an import.
9. Practical tips and summary
In practice, the combination of all the building blocks covered here works well: allow_add and allow_delete for the basic flexibility, by_reference: false together with adder and remover methods for correct entity mapping, a PRE_SET_DATA listener for a user-friendly initial row, and a PRE_SUBMIT listener as a safety net against oversized submit payloads. Each of these building blocks solves a specific sub-problem and can be tested independently of the others, which keeps the overall implementation maintainable despite its several moving parts.
Anyone introducing CollectionType for the first time should start with a fixed number of entries without allow_add, to verify entity mapping and validation, before adding JavaScript interaction and PRE_SUBMIT protection. This step-by-step approach cleanly separates the three independent sources of bugs, mapping, frontend interaction and security, from each other, and makes debugging early problems noticeably easier than implementing every building block at once.
| Building block | Purpose | Configured where | Typical problem without it |
|---|---|---|---|
| allow_add / allow_delete | Accept a variable number of entries on submit | CollectionType options | Only exactly the originally rendered count is accepted |
| by_reference: false + adder/remover | Correct mapping onto the entity collection | CollectionType option + entity methods | The inverse side of the relationship stays inconsistent |
| Prototype + JavaScript | Add new rows without a page reload | Twig data-prototype + Stimulus/JS | Users can only see the initially rendered rows |
| PRE_SET_DATA listener | Initial empty row for new entities | FormType class | Empty form with no visible first row |
| PRE_SUBMIT listener | Enforce an upper bound on submitted entries | FormType class | Arbitrarily large payloads can burden the server |
Mironsoft
Symfony architecture, clean domain logic, and legacy modernization
Symfony applications that stay maintainable two years down the line?
We review existing Symfony projects for bloated controllers, missing service abstractions, and untested core logic, then build an architecture that absorbs new features without getting more fragile with every release.
Architecture Review
Checking bundle structure, dependency injection, and service abstractions for maintainability.
Legacy Modernization
Incrementally migrating outdated Symfony versions without a full rewrite.
Testing and Quality Assurance
Setting up PHPUnit, PHPStan, and CI pipelines for lasting code quality.
10. Summary
CollectionType: The Essentials at a Glance
Core problem
A fixed form structure cannot represent a runtime-variable number of similar sub-forms.
Solution
CollectionType with allow_add/allow_delete, by_reference: false and adder/remover methods on the entity.
Frontend integration
Cloning the prototype attribute via JavaScript adds new rows without a page reload.
Security
PRE_SUBMIT caps the number of submitted entries against oversized payloads.