Skip to content

Incorrect form error CollectionType name mapping #60722

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
justin-oh opened this issue Jun 5, 2025 · 2 comments
Open

Incorrect form error CollectionType name mapping #60722

justin-oh opened this issue Jun 5, 2025 · 2 comments

Comments

@justin-oh
Copy link

justin-oh commented Jun 5, 2025

Symfony version(s) affected

6.4

Description

When working with a dynamic add/delete OneToMany collection on the frontend, I take the approach of incrementing a unique index value to replace the prototype.vars.name string. As opposed to trying to ensure a 0, 1, 2, 3, etc. order which can be a pain, especially with nested collections.

The user may submit things in an order like 1, 3, 4, 7, etc. depending how they added/removed things. This works if there are no form errors.

The bug happens when the form errors kick in. If the user submits collection children with indices 1, 3, 4, 7, they render fine with said indices, but the errors are trying to map to 1, 2, 3, 4, etc. It seems like the errors get the first index and just start incrementing by 1 instead of using the actual indices.

How to reproduce

The following code is a trimmed down and renamed version of my code, so no promises that the following 100% works.

Parent entity:

<?php

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
class Stuff
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 25, nullable: true)]
    #[Assert\Length(max: 25)]
    #[Assert\NotBlank]
    private ?string $name = null;

    /**
     * @var Collection<int, Thing>
     */
    #[ORM\OneToMany(targetEntity: Thing::class, mappedBy: 'stuff', orphanRemoval: true, cascade: ['persist', 'remove'])]
    #[Assert\Valid]
    #[Assert\Count(min: 0, max: 4)]
    private Collection $things;

    public function __construct()
    {
        $this->things = new ArrayCollection();
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(?string $name): static
    {
        $this->name = $name;

        return $this;
    }

    /**
     * @return Collection<int, Thing>
     */
    public function getThings(): Collection
    {
        return $this->things;
    }

    public function addThing(Thing $thing): static
    {
        if (!$this->things->contains($thing)) {
            $this->things->add($thing);
            $thing->setStuff($this);
        }

        return $this;
    }

    public function removeThing(Thing $thing): static
    {
        if ($this->things->removeElement($thing)) {
            // set the owning side to null (unless already changed)
            if ($thing->getThing() === $this) {
                $thing->setStuff(null);
            }
        }

        return $this;
    }
}

Child entity:

<?php

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
class Thing
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 25, nullable: true)]
    #[Assert\Length(max: 25)]
    #[Assert\NotBlank]
    private ?string $name = null;

    #[ORM\ManyToOne(inversedBy: 'things')]
    #[ORM\JoinColumn(nullable: false)]
    private ?Stuff $stuff = null;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getName(): ?string
    {
        return $this->name;
    }

    public function setName(?string $name): static
    {
        $this->name = $name;

        return $this;
    }

    public function getStuff(): ?Stuff
    {
        return $this->stuff;
    }

    public function setStuff(?Stuff $stuff): static
    {
        $this->stuff = $stuff;

        return $this;
    }
}

Parent form:

<?php

namespace App\Form;

use App\Entity\Stuff;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class StuffType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('name', TextType::class);

        $builder->add('things', CollectionType::class, [
            'entry_type' => ThingType::class,
            'allow_add' => true,
            'allow_delete' => true,
            'by_reference' => false,
            'error_bubbling' => false,
        ]);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Stuff::class,
        ]);
    }
}

Child form:

<?php

namespace App\Form;

use App\Entity\Thing;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class ThingType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->add('name', TextType::class);
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Thing::class,
        ]);
    }
}

Main form template:

{{ form_start(form) }}
    {% include 'form/collection.html.twig' with {
    collection: form.things,
    noun: 'Thing',
    max: 4,
  } %}
{{ form_end(form) }}

Collection helper template:

{# there was a custom Twig function here to generate an actual unique value #}
{% set uniq_id = 'abc123' %}

{% macro render_item(item, noun) %}
  <fieldset class="mb-3">
    <legend class="col-form-label required">
      {{ noun }} #<span data-number></span>
    </legend>
    <div id="{{ item.vars.id }}">
      {{ form_widget(item) }}
      <a class="btn btn-danger btn-sm" href="#">
        Delete {{ noun }}
      </a>
    </div>
  </fieldset>
{% endmacro %}

{% do collection.setRendered %}

<div id="container_{{ uniq_id }}">
  {% for child in collection.children %}
    {{ _self.render_item(child, noun) }}
  {% endfor %}
</div>

{{ form_errors(collection) }}

<div class="mb-3">
  <button type="button" id="add_{{ uniq_id }}" class="btn btn-primary btn-sm">
    Add {{ noun }}
  </button>
</div>

<template id="template_{{ uniq_id }}">{{ _self.render_item(collection.vars.prototype, noun) }}</template>

<script>
document.addEventListener('DOMContentLoaded', function() {
  const max = {{ max|json_encode|raw }};

  const template = document.getElementById('template_{{ uniq_id }}');
  const name = '{{ collection.vars.prototype.vars.name }}';

  const addButton = document.getElementById('add_{{ uniq_id }}');
  const container = document.getElementById('container_{{ uniq_id }}');

  {# 
    previously I wrongly assumed items were always rendered 0, 1, 2, 3
    and initialized this value based on the length of collection.children
  #}
  let index = {{ collection.children ? max(collection.children|keys) : -1 }};

  function updateNumbers() {
    for (let i = 0; i < container.children.length; i++) {
      const el = container.children.item(i);

      el.querySelector('[data-number]').textContent = i + 1;
    }
  }

  async function initEl(el) {
    const deleteButton = el.querySelector('a.btn-danger');

    deleteButton.addEventListener('click', async function(e) {
      e.preventDefault();

      if (confirm('Are you sure you want to delete this {{ noun }}?')) {
        el.remove();

        if (container.children.length < max) {
          addButton.disabled = false;
        }

        updateNumbers();
      }
    });

    if (container.children.length >= max) {
      addButton.disabled = true;
    }

    updateNumbers();
  }

  function addEl() {
    if (container.children.length >= max) {
      return;
    }

    index++;

    const el = template.content.firstElementChild.cloneNode(true);

    el.innerHTML = el.innerHTML.replaceAll(name, index);

    container.append(el);

    initEl(el);
  }

  addButton.addEventListener('click', async function(e) {
    e.preventDefault();

    addEl();
  });

  for (let i = 0; i < container.children.length; i++) {
    initEl(container.children.item(i));
  }
});
</script>

In the controller, I'm not doing anything out of the ordinary:

    #[Route('/stuff/{id}', name: 'stuff_edit', methods: ['GET', 'POST'])]
    public function edit(
        EntityManagerInterface $em,
        Request $request,
        #[MapEntity(id: 'id')] Stuff $stuff,
    ): Response {
        // access checking

        $form = $this->createForm(StuffType::class, $stuff);

        $form->add('submit', SubmitType::class);

        $form->handleRequest($request);

        if ($form->isSubmitted()) {
            if ($form->isValid()) {
                $em->persist($stuff);
                $em->flush();

                $this->addFlash('notice', 'Stuff updated successfully.');

                return $this->redirectToRoute('stuff_edit', [
                    'id' => $stuff->getId(),
                ]);
            }

            $this->addFlash('error', 'There are some errors in the form below.');
        }

        return $this->render('form/stuff_form.html.twig', [
            'form' => $form->createView(),
            'stuff' => $stuff,
        ]);
    }

Possible Solution

If possible, the form errors should not increment based on the first index but follow the indices of the actual form elements.

Additional Context

This is from the debugger category for Forms. You can see the Forms are indexed 1, 3, 4, 7.

Image

This is from the debugger category for Validator. You can see it is trying to map errors to 1, 2, 3, 4.

Image

What happens is the form renders:

  • index 1 as the 1st item which gets error 1
  • index 3 as the 2nd item which gets error 3
  • index 4 as the 3rd item which gets error 4
  • index 7 as the 4th item which gets no error
  • then the unmapped error 2 is bubbled to {{ form_errors(collection) }}
@justin-oh
Copy link
Author

I can confirm if I only add elements such that the indices are 0, 1, 2, etc. then cause errors, the form validator tries to map to 0, 1, 2, etc.

If the index doesn't start at 0, the form validator starts mapping 1, 2, 3, etc. even though I just tried submitting 2, 3, 4, etc.

@xabbuh xabbuh added the Form label Jun 6, 2025
@xabbuh
Copy link
Member

xabbuh commented Jun 13, 2025

Can you create a small example application that allows to reproduce your issue?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants