How to Embed a Collection of Forms

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. // src/Entity/Task.php
  2. namespace App\Entity;
  3. use Doctrine\Common\Collections\ArrayCollection;
  4. use Doctrine\Common\Collections\Collection;
  5. class Task
  6. {
  7. protected $description;
  8. protected $tags;
  9. public function __construct()
  10. {
  11. $this->tags = new ArrayCollection();
  12. }
  13. public function getDescription(): string
  14. {
  15. return $this->description;
  16. }
  17. public function setDescription(string $description): void
  18. {
  19. $this->description = $description;
  20. }
  21. public function getTags(): Collection
  22. {
  23. return $this->tags;
  24. }
  25. }

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. // src/Entity/Tag.php
  2. namespace App\Entity;
  3. class Tag
  4. {
  5. private $name;
  6. public function getName(): string
  7. {
  8. return $this->name;
  9. }
  10. public function setName(string $name): void
  11. {
  12. $this->name = $name;
  13. }
  14. }

Then, create a form class so that a Tag object can be modified by the user:

  1. // src/Form/TagType.php
  2. namespace App\Form;
  3. use App\Entity\Tag;
  4. use Symfony\Component\Form\AbstractType;
  5. use Symfony\Component\Form\FormBuilderInterface;
  6. use Symfony\Component\OptionsResolver\OptionsResolver;
  7. class TagType extends AbstractType
  8. {
  9. public function buildForm(FormBuilderInterface $builder, array $options): void
  10. {
  11. $builder->add('name');
  12. }
  13. public function configureOptions(OptionsResolver $resolver): void
  14. {
  15. $resolver->setDefaults([
  16. 'data_class' => Tag::class,
  17. ]);
  18. }
  19. }

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. // src/Form/TaskType.php
  2. namespace App\Form;
  3. use App\Entity\Task;
  4. use Symfony\Component\Form\AbstractType;
  5. use Symfony\Component\Form\Extension\Core\Type\CollectionType;
  6. use Symfony\Component\Form\FormBuilderInterface;
  7. use Symfony\Component\OptionsResolver\OptionsResolver;
  8. class TaskType extends AbstractType
  9. {
  10. public function buildForm(FormBuilderInterface $builder, array $options): void
  11. {
  12. $builder->add('description');
  13. $builder->add('tags', CollectionType::class, [
  14. 'entry_type' => TagType::class,
  15. 'entry_options' => ['label' => false],
  16. ]);
  17. }
  18. public function configureOptions(OptionsResolver $resolver): void
  19. {
  20. $resolver->setDefaults([
  21. 'data_class' => Task::class,
  22. ]);
  23. }
  24. }

In your controller, you’ll create a new form from the TaskType:

  1. // src/Controller/TaskController.php
  2. namespace App\Controller;
  3. use App\Entity\Tag;
  4. use App\Entity\Task;
  5. use App\Form\TaskType;
  6. use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
  7. use Symfony\Component\HttpFoundation\Request;
  8. use Symfony\Component\HttpFoundation\Response;
  9. class TaskController extends AbstractController
  10. {
  11. public function new(Request $request): Response
  12. {
  13. $task = new Task();
  14. // dummy code - add some example tags to the task
  15. // (otherwise, the template will render an empty list of tags)
  16. $tag1 = new Tag();
  17. $tag1->setName('tag1');
  18. $task->getTags()->add($tag1);
  19. $tag2 = new Tag();
  20. $tag2->setName('tag2');
  21. $task->getTags()->add($tag2);
  22. // end dummy code
  23. $form = $this->createForm(TaskType::class, $task);
  24. $form->handleRequest($request);
  25. if ($form->isSubmitted() && $form->isValid()) {
  26. // ... do your form processing, like saving the Task and Tag entities
  27. }
  28. return $this->render('task/new.html.twig', [
  29. 'form' => $form->createView(),
  30. ]);
  31. }
  32. }

In the template, you can now iterate over the existing TagType forms to render them:

  1. {# templates/task/new.html.twig #}
  2. {# ... #}
  3. {{ form_start(form) }}
  4. {{ form_row(form.description) }}
  5. <h3>Tags</h3>
  6. <ul class="tags">
  7. {% for tag in form.tags %}
  8. <li>{{ form_row(tag.name) }}</li>
  9. {% endfor %}
  10. </ul>
  11. {{ form_end(form) }}
  12. {# ... #}

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.

Caution

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.

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. // src/Form/TaskType.php
  2. // ...
  3. public function buildForm(FormBuilderInterface $builder, array $options): void
  4. {
  5. // ...
  6. $builder->add('tags', CollectionType::class, [
  7. 'entry_type' => TagType::class,
  8. 'entry_options' => ['label' => false],
  9. 'allow_add' => true,
  10. ]);
  11. }

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. To render the prototype, add the following data-prototype attribute to the existing <ul> in your template:

  1. <ul class="tags" data-prototype="{{ form_widget(form.tags.vars.prototype)|e('html_attr') }}"></ul>

Now add a button just next to the <ul> to dynamically add a new tag:

  1. <button type="button" class="add_item_link" data-collection-holder-class="tags">Add a tag</button>

On the rendered page, the result will look something like this:

  1. <ul class="tags" data-prototype="&lt;div&gt;&lt;label class=&quot; required&quot;&gt;__name__&lt;/label&gt;&lt;div id=&quot;task_tags___name__&quot;&gt;&lt;div&gt;&lt;label for=&quot;task_tags___name___name&quot; class=&quot; required&quot;&gt;Name&lt;/label&gt;&lt;input type=&quot;text&quot; id=&quot;task_tags___name___name&quot; name=&quot;task[tags][__name__][name]&quot; required=&quot;required&quot; maxlength=&quot;255&quot; /&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;">

See also

If you want to customize the HTML code in the prototype, see Fragment Naming for Collections.

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. This example uses jQuery and assumes you have it included somewhere on your page (e.g. using Symfony’s Webpack Encore).

Add a <script> tag somewhere on your page to include the required functionality with JavaScript:

  1. jQuery(document).ready(function() {
  2. // Get the ul that holds the collection of tags
  3. var $tagsCollectionHolder = $('ul.tags');
  4. // count the current form inputs we have (e.g. 2), use that as the new
  5. // index when inserting a new item (e.g. 2)
  6. $tagsCollectionHolder.data('index', $tagsCollectionHolder.find('input').length);
  7. $('body').on('click', '.add_item_link', function(e) {
  8. var $collectionHolderClass = $(e.currentTarget).data('collectionHolderClass');
  9. // add a new tag form (see next code block)
  10. addFormToCollection($collectionHolderClass);
  11. })
  12. });

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. function addFormToCollection($collectionHolderClass) {
  2. // Get the ul that holds the collection of tags
  3. var $collectionHolder = $('.' + $collectionHolderClass);
  4. // Get the data-prototype explained earlier
  5. var prototype = $collectionHolder.data('prototype');
  6. // get the new index
  7. var index = $collectionHolder.data('index');
  8. var newForm = prototype;
  9. // You need this only if you didn't set 'label' => false in your tags field in TaskType
  10. // Replace '__name__label__' in the prototype's HTML to
  11. // instead be a number based on how many items we have
  12. // newForm = newForm.replace(/__name__label__/g, index);
  13. // Replace '__name__' in the prototype's HTML to
  14. // instead be a number based on how many items we have
  15. newForm = newForm.replace(/__name__/g, index);
  16. // increase the index with one for the next item
  17. $collectionHolder.data('index', index + 1);
  18. // Display the form in the page in an li, before the "Add a tag" link li
  19. var $newFormLi = $('<li></li>').append(newForm);
  20. // Add the new form at the end of the list
  21. $collectionHolder.append($newFormLi)
  22. }

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.

To make handling these new tags easier, add an “adder” and a “remover” method for the tags in the Task class:

  1. // src/Entity/Task.php
  2. namespace App\Entity;
  3. // ...
  4. class Task
  5. {
  6. // ...
  7. public function addTag(Tag $tag): void
  8. {
  9. $this->tags->add($tag);
  10. }
  11. public function removeTag(Tag $tag): void
  12. {
  13. // ...
  14. }
  15. }

Next, add a by_reference option to the tags field and set it to false:

  1. // src/Form/TaskType.php
  2. // ...
  3. public function buildForm(FormBuilderInterface $builder, array $options): void
  4. {
  5. // ...
  6. $builder->add('tags', CollectionType::class, [
  7. // ...
  8. 'by_reference' => false,
  9. ]);
  10. }

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

Caution

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.

Caution

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.

Doctrine: Cascading Relations and saving the “Inverse” side

To save the new tags with Doctrine, you need to consider a couple more things. First, unless you iterate over all of the new Tag objects and call $entityManager->persist($tag) on each, you’ll receive an error from Doctrine:

A new entity was found through the relationship App\Entity\Task#tags that was not configured to cascade persist operations for entity…

To fix this, you may choose to “cascade” the persist operation automatically from the Task object to any related tags. To do this, add the cascade option to your ManyToMany metadata:

  • Annotations

    1. // src/Entity/Task.php
    2. // ...
    3. /**
    4. * @ORM\ManyToMany(targetEntity="App\Entity\Tag", cascade={"persist"})
    5. */
    6. protected $tags;
  • YAML

    1. # src/Resources/config/doctrine/Task.orm.yaml
    2. App\Entity\Task:
    3. type: entity
    4. # ...
    5. oneToMany:
    6. tags:
    7. targetEntity: App\Entity\Tag
    8. cascade: [persist]
  • XML

    1. <!-- src/Resources/config/doctrine/Task.orm.xml -->
    2. <?xml version="1.0" encoding="UTF-8" ?>
    3. <doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
    4. xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    5. xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping
    6. https://doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
    7. <entity name="App\Entity\Task">
    8. <!-- ... -->
    9. <one-to-many field="tags" target-entity="Tag">
    10. <cascade>
    11. <cascade-persist/>
    12. </cascade>
    13. </one-to-many>
    14. </entity>
    15. </doctrine-mapping>

A second potential issue deals with the Owning Side and Inverse Side of Doctrine relationships. In this example, if the “owning” side of the relationship is “Task”, then persistence will work fine as the tags are properly added to the Task. However, if the owning side is on “Tag”, then you’ll need to do a little bit more work to ensure that the correct side of the relationship is modified.

The trick is to make sure that the single “Task” is set on each “Tag”. One way to do this is to add some extra logic to addTag(), which is called by the form type since by_reference is set to false:

  1. // src/Entity/Task.php
  2. // ...
  3. public function addTag(Tag $tag): void
  4. {
  5. // for a many-to-many association:
  6. $tag->addTask($this);
  7. // for a many-to-one association:
  8. $tag->setTask($this);
  9. $this->tags->add($tag);
  10. }

If you’re going for addTask(), make sure you have an appropriate method that looks something like this:

  1. // src/Entity/Tag.php
  2. // ...
  3. public function addTask(Task $task): void
  4. {
  5. if (!$this->tasks->contains($task)) {
  6. $this->tasks->add($task);
  7. }
  8. }

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. // src/Form/TaskType.php
  2. // ...
  3. public function buildForm(FormBuilderInterface $builder, array $options): void
  4. {
  5. // ...
  6. $builder->add('tags', CollectionType::class, [
  7. // ...
  8. 'allow_delete' => true,
  9. ]);
  10. }

Now, you need to put some code into the removeTag() method of Task:

  1. // src/Entity/Task.php
  2. // ...
  3. class Task
  4. {
  5. // ...
  6. public function removeTag(Tag $tag): void
  7. {
  8. $this->tags->removeElement($tag);
  9. }
  10. }

Template Modifications

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.

First, add a “delete this tag” link to each tag form:

  1. jQuery(document).ready(function() {
  2. // Get the ul that holds the collection of tags
  3. $collectionHolder = $('ul.tags');
  4. // add a delete link to all of the existing tag form li elements
  5. $collectionHolder.find('li').each(function() {
  6. addTagFormDeleteLink($(this));
  7. });
  8. // ... the rest of the block from above
  9. });
  10. function addFormToCollection() {
  11. // ...
  12. // add a delete link to the new form
  13. addTagFormDeleteLink($newFormLi);
  14. }

The addTagFormDeleteLink() function will look something like this:

  1. function addTagFormDeleteLink($tagFormLi) {
  2. var $removeFormButton = $('<button type="button">Delete this tag</button>');
  3. $tagFormLi.append($removeFormButton);
  4. $removeFormButton.on('click', function(e) {
  5. // remove the li for the tag form
  6. $tagFormLi.remove();
  7. });
  8. }

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.

Doctrine: Ensuring the database persistence

When removing objects in this way, you may need to do a little bit more work to ensure that the relationship between the Task and the removed Tag is properly removed.

In Doctrine, you have two sides of the relationship: the owning side and the inverse side. Normally in this case you’ll have a many-to-one relationship and the deleted tags will disappear and persist correctly (adding new tags also works effortlessly).

But if you have a one-to-many relationship or a many-to-many relationship with a mappedBy on the Task entity (meaning Task is the “inverse” side), you’ll need to do more work for the removed tags to persist correctly.

In this case, you can modify the controller to remove the relationship on the removed tag. This assumes that you have some edit() action which is handling the “update” of your Task:

  1. // src/Controller/TaskController.php
  2. // ...
  3. use App\Entity\Task;
  4. use Doctrine\Common\Collections\ArrayCollection;
  5. class TaskController extends AbstractController
  6. {
  7. public function edit($id, Request $request, EntityManagerInterface $entityManager): Response
  8. {
  9. if (null === $task = $entityManager->getRepository(Task::class)->find($id)) {
  10. throw $this->createNotFoundException('No task found for id '.$id);
  11. }
  12. $originalTags = new ArrayCollection();
  13. // Create an ArrayCollection of the current Tag objects in the database
  14. foreach ($task->getTags() as $tag) {
  15. $originalTags->add($tag);
  16. }
  17. $editForm = $this->createForm(TaskType::class, $task);
  18. $editForm->handleRequest($request);
  19. if ($editForm->isSubmitted() && $editForm->isValid()) {
  20. // remove the relationship between the tag and the Task
  21. foreach ($originalTags as $tag) {
  22. if (false === $task->getTags()->contains($tag)) {
  23. // remove the Task from the Tag
  24. $tag->getTasks()->removeElement($task);
  25. // if it was a many-to-one relationship, remove the relationship like this
  26. // $tag->setTask(null);
  27. $entityManager->persist($tag);
  28. // if you wanted to delete the Tag entirely, you can also do that
  29. // $entityManager->remove($tag);
  30. }
  31. }
  32. $entityManager->persist($task);
  33. $entityManager->flush();
  34. // redirect back to some edit page
  35. return $this->redirectToRoute('task_edit', ['id' => $id]);
  36. }
  37. // ... render some form template
  38. }
  39. }

As you can see, adding and removing the elements correctly can be tricky. Unless you have a many-to-many relationship where Task is the “owning” side, you’ll need to do extra work to make sure that the relationship is properly updated (whether you’re adding new tags or removing existing tags) on each Tag object itself.

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 package for modern browsers and the symfony-collection package based on jQuery for the rest of browsers.

This work, including the code samples, is licensed under a Creative Commons BY-SA 3.0 license.