Home > OS >  Symfony5: form for self-referencing many-to-many
Symfony5: form for self-referencing many-to-many

Time:12-09

In a Symfony5 application, how can we utilize the form-component to correctly set up a self-referencing, many-to-many relationship?

Note: this is purely about the form-side of things, Doctrine is already happy.

Let's say we have a "Person"-entity, and these Person-instances host guests, who are also Persons:

#[Entity(repositoryClass: PersonRepository::class)]
class Person
{
    #[Id]
    #[GeneratedValue]
    #[Column(type: Types::INTEGER)]
    private int $id;

    #[Column(type: Types::STRING, length: 255)]
    private string $firstName;

    #[Column(type: Types::STRING, length: 255)]
    private string $lastName;

    /** @var Collection<Person> */
    #[ManyToMany(targetEntity: self::class, inversedBy: 'guestsHosted')]
    private Collection $hosts;

    /** @var Collection<Person> */
    #[ManyToMany(targetEntity: self::class, mappedBy: 'hosts')]
    private Collection $guestsHosted;

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

    // getters/setters ommitted for brevity


    public function addHost(self $host): self
    {
    }

    public function removeHost(self $host): self
    {
    }

    public function addGuestsHosted(self $guestHosted): self
    {
    }

    public function removeGuestsHosted(self $guestsHosted): self
    {
    }

What I would like to achieve is a form with a collection of addable/removable rows, where the user then is able to add a row, select an existing other person as their host, add another row, select another person, and so on. A simple mockup:

How do I achieve this using Symfony's form-component? What I have in there now does not work due to recursion on the same form-type:

class PersonType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder           
            ->add('firstName', null, [])
            ->add('lastName', null, [])            
            ->add('hosts', CollectionType::class, [
                'entry_type' => self::class,
                'entry_options' => ['label' => false],
                'required' => false,
                'allow_delete' => true,
                'allow_add' => true,
                'by_reference' => false,
            ]);
    }

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

NB: I've built similar UIs like this in the past and know about the little dance involving form.vars.prototype and hooking up the JS to that. Where I'm lost is how to map this association on the form-level, so that I get a dropdown of all Persons, and map that into the $hosts property of the entity.

Any help is appreciated!

CodePudding user response:

@Bossman's comment is pretty correct, but just in case you want to do it with a single form type, I'd do something like this:

class PersonType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder           
            ->add('firstName', null, [])
            ->add('lastName', null, []);

        if (!$options['is_host']) {            
            ->add('hosts', CollectionType::class, [
                'entry_type' => self::class,
                'entry_options' => ['label' => false, 'is_host' => true],
                'required' => false,
                'allow_delete' => true,
                'allow_add' => true,
                'by_reference' => false,
            ]);
        }
    }

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

Basically what I'm doing here is preventing recursion by explicitly passing 'is_host' to the form options, which indicates whether this is a sub-entity, and then I'm building 'hosts' form field only for the root entity.

If you want to select among existing hosts, use EntityType:

class PersonType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        // You'll be able to use this later in the 'query_builder' option
        $personId = $options['person_id'];

        $builder
            ->add('firstName', null, [])
            ->add('lastName', null, [])
            ->add('hosts', CollectionType::class, [
                'entry_type' => EntityType::class,
                'entry_options' => ['label' => false, 'class' => Person::class],
                'required' => false,
                'allow_delete' => true,
                'allow_add' => true,
                'by_reference' => false,
            ])
        }
    }

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

        $resolver->setAllowedTypes('person_id', ['int', 'null']);
    }
}

It's important to remember the following:

  1. You will have to pass current person id as person_id options of the form (or null for new entries). You may also retrieve this value from the $builder variable if you want.

  2. Modify 'query_builder' in the EntityType options for your needs, e.g. don't exclude current (root) person from the hosts list, etc.

  3. This approach MIGHT lead to multiple DB queries (1 per each collection item), but I'm not sure about it right now. If it does, you should find a solution for this problem by yourself.

  • Related