How to Embed a Collection of Forms
Symfony Forms can embed a collection of many other forms, which is useful to
edit related entities in a single form. In this article, you'll create a form to
edit a Task class and, right inside the same form, you'll be able to edit,
create and remove many Tag objects related to that Task.
Let's start by creating a Task entity:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
// src/Entity/Task.php
namespace App\Entity;
use Doctrine\Common\Collections\Collection;
class Task
{
protected string $description;
protected Collection $tags;
public function __construct()
{
$this->tags = new ArrayCollection();
}
public function getDescription(): string
{
return $this->description;
}
public function setDescription(string $description): void
{
$this->description = $description;
}
public function getTags(): Collection
{
return $this->tags;
}
}
Note
The ArrayCollection is specific to Doctrine and is similar to a PHP array but provides many utility methods.
Now, create a Tag class. As you saw above, a Task can have many Tag
objects:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
// src/Entity/Tag.php
namespace App\Entity;
class Tag
{
private string $name;
public function getName(): string
{
return $this->name;
}
public function setName(string $name): void
{
$this->name = $name;
}
}
Then, create a form class so that a Tag object can be modified by the user:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
// src/Form/TagType.php
namespace App\Form;
use App\Entity\Tag;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TagType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('name');
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Tag::class,
]);
}
}
Next, let's create a form for the Task entity, using a
CollectionType field of TagType
forms. This will allow us to modify all the Tag elements of a Task right
inside the task form itself:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
// src/Form/TaskType.php
namespace App\Form;
use App\Entity\Task;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TaskType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder->add('description');
$builder->add('tags', CollectionType::class, [
'entry_type' => TagType::class,
'entry_options' => ['label' => false],
]);
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => Task::class,
]);
}
}
In your controller, you'll create a new form from the TaskType:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39
// src/Controller/TaskController.php
namespace App\Controller;
use App\Entity\Tag;
use App\Entity\Task;
use App\Form\TaskType;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
class TaskController extends AbstractController
{
public function new(Request $request): Response
{
$task = new Task();
// dummy code - add some example tags to the task
// (otherwise, the template will render an empty list of tags)
$tag1 = new Tag();
$tag1->setName('tag1');
$task->getTags()->add($tag1);
$tag2 = new Tag();
$tag2->setName('tag2');
$task->getTags()->add($tag2);
// end dummy code
$form = $this->createForm(TaskType::class, $task);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// ... do your form processing, like saving the Task and Tag entities
}
return $this->render('task/new.html.twig', [
'form' => $form,
]);
}
}
In the template, you can now iterate over the existing TagType forms
to render them:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
{# templates/task/new.html.twig #}
{# ... #}
{{ form_start(form) }}
{{ form_row(form.description) }}
<h3>Tags</h3>
<ul class="tags">
{% for tag in form.tags %}
<li>{{ form_row(tag.name) }}</li>
{% endfor %}
</ul>
{{ form_end(form) }}
{# ... #}
When the user submits the form, the submitted data for the tags field is
used to construct an ArrayCollection of Tag objects. The collection is
then set on the tag field of the Task and can be accessed via $task->getTags().
So far, this works great, but only to edit existing tags. It doesn't allow us yet to add new tags or delete existing ones.
Warning
You can embed nested collections as many levels down as you like. However,
if you use Xdebug, you may receive a Maximum function nesting level of '100'
reached, aborting! error. To fix this, increase the xdebug.max_nesting_level
PHP setting, or render each form field by hand using form_row() instead of
rendering the whole form at once (e.g form_widget(form)).
Allowing "new" Tags with the "Prototype"
Previously you added two tags to your task in the controller. Now let the users add as many tag forms as they need directly in the browser. This requires a bit of JavaScript code.
Tip
Instead of writing the needed JavaScript code yourself, you can use Symfony UX to implement this feature with only PHP and Twig code. See the Symfony UX Demo of Form Collections.
But first, you need to let the form collection know that instead of exactly two,
it will receive an unknown number of tags. Otherwise, you'll see a
"This form should not contain extra fields" error. This is done with the
allow_add option:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
// src/Form/TaskType.php
// ...
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// ...
$builder->add('tags', CollectionType::class, [
'entry_type' => TagType::class,
'entry_options' => ['label' => false],
'allow_add' => true,
]);
}
The allow_add option also makes a prototype variable available to you.
This "prototype" is a little "template" that contains all the HTML needed to
dynamically create any new "tag" forms with JavaScript.
Let's start with plain JavaScript (Vanilla JS) – if you're using Stimulus, see below.
To render the prototype, add
the following data-prototype attribute to the existing <ul> in your
template:
1 2 3 4 5
{# the data-index attribute is required for the JavaScript code below #}
<ul class="tags"
data-index="{{ form.tags|length > 0 ? form.tags|last.vars.name + 1 : 0 }}"
data-prototype="{{ form_widget(form.tags.vars.prototype)|e('html_attr') }}"
></ul>
On the rendered page, the result will look something like this:
1 2 3 4
<ul class="tags"
data-index="0"
data-prototype="<div><label class=" required">__name__</label><div id="task_tags___name__"><div><label for="task_tags___name___name" class=" required">Name</label><input type="text" id="task_tags___name___name" name="task[tags][__name__][name]" required="required" maxlength="255" /></div></div></div>"
></ul>
Now add a button to dynamically add a new tag:
1
<button type="button" class="add_item_link" data-collection-holder-class="tags">Add a tag</button>
See also
If you want to customize the HTML code in the prototype, see How to Work with Form Themes.
Tip
The form.tags.vars.prototype is a form element that looks and feels just
like the individual form_widget(tag.*) elements inside your for loop.
This means that you can call form_widget(), form_row() or form_label()
on it. You could even choose to render only one of its fields (e.g. the
name field):
1
{{ form_widget(form.tags.vars.prototype.name)|e }}
Note
If you render your whole "tags" sub-form at once (e.g. form_row(form.tags)),
the data-prototype attribute is automatically added to the containing div,
and you need to adjust the following JavaScript accordingly.
Now add some JavaScript to read this attribute and dynamically add new tag forms
when the user clicks the "Add a tag" link. Add a <script> tag somewhere
on your page to include the required functionality with JavaScript:
1 2 3 4 5
document
.querySelectorAll('.add_item_link')
.forEach(btn => {
btn.addEventListener("click", addFormToCollection)
});
The addFormToCollection() function's job will be to use the data-prototype
attribute to dynamically add a new form when this link is clicked. The data-prototype
HTML contains the tag's text input element with a name of task[tags][__name__][name]
and id of task_tags___name___name. The __name__ is a placeholder, which
you'll replace with a unique, incrementing number (e.g. task[tags][3][name]):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
function addFormToCollection(e) {
const collectionHolder = document.querySelector('.' + e.currentTarget.dataset.collectionHolderClass);
const item = document.createElement('li');
item.innerHTML = collectionHolder
.dataset
.prototype
.replace(
/__name__/g,
collectionHolder.dataset.index
);
collectionHolder.appendChild(item);
collectionHolder.dataset.index++;
};
Now, each time a user clicks the Add a tag link, a new sub form will
appear on the page. When the form is submitted, any new tag forms will be converted
into new Tag objects and added to the tags property of the Task object.
See also
You can find a working example in this JSFiddle.
JavaScript with Stimulus
If you're using Stimulus, wrap everything in a <div>:
1 2 3 4 5 6 7
<div {{ stimulus_controller('form-collection') }}
data-form-collection-index-value="{{ form.tags|length > 0 ? form.tags|last.vars.name + 1 : 0 }}"
data-form-collection-prototype-value="{{ form_widget(form.tags.vars.prototype)|e('html_attr') }}"
>
<ul {{ stimulus_target('form-collection', 'collectionContainer') }}></ul>
<button type="button" {{ stimulus_action('form-collection', 'addCollectionElement') }}>Add a tag</button>
</div>
Then create the controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
// assets/controllers/form-collection_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ["collectionContainer"]
static values = {
index : Number,
prototype: String,
}
addCollectionElement(event)
{
const item = document.createElement('li');
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.indexValue);
this.collectionContainerTarget.appendChild(item);
this.indexValue++;
}
}
Handling the new Tags in PHP
To make handling these new tags easier, add an "adder" and a "remover" method
for the tags in the Task class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
// src/Entity/Task.php
namespace App\Entity;
// ...
class Task
{
// ...
public function addTag(Tag $tag): void
{
$this->tags->add($tag);
}
public function removeTag(Tag $tag): void
{
// ...
}
}
Next, add a by_reference option to the tags field and set it to false:
1 2 3 4 5 6 7 8 9 10 11 12
// src/Form/TaskType.php
// ...
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// ...
$builder->add('tags', CollectionType::class, [
// ...
'by_reference' => false,
]);
}
With these two changes, when the form is submitted, each new Tag object
is added to the Task class by calling the addTag() method. Before this
change, they were added internally by the form by calling $task->getTags()->add($tag).
That was fine, but forcing the use of the "adder" method makes handling
these new Tag objects easier (especially if you're using Doctrine, which
you will learn about next!).
Warning
You have to create both addTag() and removeTag() methods,
otherwise the form will still use setTag() even if by_reference is false.
You'll learn more about the removeTag() method later in this article.
Warning
Symfony can only make the plural-to-singular conversion (e.g. from the
tags property to the addTag() method) for English words. Code
written in any other language won't work as expected.
Allowing Tags to be Removed
The next step is to allow the deletion of a particular item in the collection. The solution is similar to allowing tags to be added.
Start by adding the allow_delete option in the form Type:
1 2 3 4 5 6 7 8 9 10 11 12
// src/Form/TaskType.php
// ...
public function buildForm(FormBuilderInterface $builder, array $options): void
{
// ...
$builder->add('tags', CollectionType::class, [
// ...
'allow_delete' => true,
]);
}
Now, you need to put some code into the removeTag() method of Task:
1 2 3 4 5 6 7 8 9 10 11 12
// src/Entity/Task.php
// ...
class Task
{
// ...
public function removeTag(Tag $tag): void
{
$this->tags->removeElement($tag);
}
}
The allow_delete option means that if an item of a collection
isn't sent on submission, the related data is removed from the collection
on the server. In order for this to work in an HTML form, you must remove
the DOM element for the collection item to be removed, before submitting
the form.
In the JavaScript code, add a "delete" button to each existing tag on the page. Then, append the "add delete button" method in the function that adds the new tags:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
document
.querySelectorAll('ul.tags li')
.forEach((tag) => {
addTagFormDeleteLink(tag)
})
// ... the rest of the block from above
function addFormToCollection(e) {
// ...
// add a delete link to the new form
addTagFormDeleteLink(item);
}
The addTagFormDeleteLink() function will look something like this:
1 2 3 4 5 6 7 8 9 10 11 12
function addTagFormDeleteLink(item) {
const removeFormButton = document.createElement('button');
removeFormButton.innerText = 'Delete this tag';
item.append(removeFormButton);
removeFormButton.addEventListener('click', (e) => {
e.preventDefault();
// remove the li for the tag form
item.remove();
});
}
When a tag form is removed from the DOM and submitted, the removed Tag object
will not be included in the collection passed to setTags(). Depending on
your persistence layer, this may or may not be enough to actually remove
the relationship between the removed Tag and Task object.
See also
The Symfony community has created some JavaScript packages that provide the functionality needed to add, edit and delete elements of the collection. Check out the @a2lix/symfony-collection or search on GitHub for other recent packages.