Home > Mobile >  Symfony: How to persist new entities with ManytoMany relation in embedded form
Symfony: How to persist new entities with ManytoMany relation in embedded form

Time:01-17

Use case

TLDR;

I have two entities with a ManytoMany relation. I want to persist two new objects at the same time with one single form. To do so, I created two FromTypes with one embedding the other.

A bit more...

The goal is to provide users with a form to make an inquiry for an event. The Event entity consists of properties like starttime, endtime e.g. that are simple properties of Event aswell as a location (Location entity with a OneToMany relation, one Event has one Location, one Location can have many Events) and a contactperson (Contact entity with a ManyToMany relation, one Event can have multiple Contacts, one Contact can have multiple Events). For the particular form in question it is enough (and a deliberate choice) for the user to provide only one Contact as that is the bare minimum needed and enough for a start.

To build reusable forms, there are two simple forms with LocationFormType and ContactFormType and a more complex EventFormType. More complex as it embedds both LocationFormType and ContactFormType to create an Event entity "in one go" so to speak. When I build the EventFormType with option A (see code below), the form renders correct and the way it is intended. Everything looks fine until the form is submitted. Then the problem starts...

Problem

On $form->handleRequest() the FormSystem throws an error because the embedded form is not providing a Traversable for the related object.

The property "contact" in class "App\Entity\Event" can be defined with the methods "addContact()", "removeContact()" but the new value must be an array or an instance of \Traversable.

Obviously the embedded FormType is providing a single object, while the property for the relation needs a Collection. When I use CollectionType for embedding (option B, see code below), the form is not rendering anymore as CollectionType seemingly expects entities to be present already. But I want to create a new one. So there is no object I could pass.

My Code

#[ORM\Entity(repositoryClass: EventRepository::class)]
class Event
{
    ...

    #[ORM\ManyToMany(targetEntity: Contact::class, inversedBy: 'events')]
    ...
    private Collection $contact;
    
    ...

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

    /**
     * @return Collection<int, Contact>
     */
    public function getContact(): Collection
    {
        return $this->contact;
    }

    public function addContact(Contact $contact): self
    {
        if (!$this->contact->contains($contact)) {
            $this->contact->add($contact);
        }

        return $this;
    }

    public function removeContact(Contact $contact): self
    {
        $this->contact->removeElement($contact);

        return $this;
    }
    
    ...
}
#[ORM\Entity(repositoryClass: ContactRepository::class)]
class Contact
{
    ...

    #[ORM\ManyToMany(targetEntity: Event::class, mappedBy: 'contact')]
    private Collection $events;

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

    /**
     * @return Collection<int, Event>
     */
    public function getEvents(): Collection
    {
        return $this->events;
    }

    public function addEvent(Event $event): self
    {
        if (!$this->events->contains($event)) {
            $this->events->add($event);
            $event->addContact($this);
        }

        return $this;
    }

    public function removeEvent(Event $event): self
    {
        if ($this->events->removeElement($event)) {
            $event->removeContact($this);
        }

        return $this;
    }
}
class EventFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ...
            // option A: embedding related FormType directly
            ->add('contact', ContactFormType::class, [
                ...
            ])
            // option B: embedding with CollectionType
            ->add('contact', CollectionType::class, [
                'entry_type' => ContactFormType::class
            ])
        ;
    }

    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Event::class,
        ]);
    }
}
class ContactFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder
            ->add(
            ... // here I only add the fields for Contact entity, no special config
            )
        ;
    }

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

Failed solutions

'allow_add' => true with prototype

I found solutions suggesting to set 'allow_add' => true on the CollectionType and render the form in Twig with ..vars.prototype Thats a hacky solution (so I think) in my use case. I don't want to add multiple forms. And without 'allow_add' there is no prototype in CollectionType, so the data to render the form is missing.

provide empty object to CollectionType

To omit 'allow_add' => true but have an object to render the form correctly, I tried passing an empty instance of Contact in my controller

$eventForm = $this->createForm(EventFormType::class);
if(!$eventForm->get('contact')) $eventForm->get('contact')->setData(array(new Contact()));

That works on initial load, but creates issues when the form is submitted. Maybe I could make it work, but my gut gives me 'hacky vibes' once again.

Conclusion

Actually I think I'm missing some basic point here as I think my use case is nothing edgy or in any way unusual. Can anyone give me a hint as where I'm going wrong with my approach?

P.S.: I'm unsure wether my issue was discussed (without a solution) over on Github.

CodePudding user response:

Okay, so I solved the problem. For this scenario one has to make use of Data Mappers.

It is possible to map single form fields by using the 'getter' and 'setter' option keys (Docs). In this particular case the setter-option is enough:

        ->add('contact', ContactFormType::class, [
            ...
            'setter' => function (Event &$event, Contact $contact, FormInterface $form) {
                $event->addContact($contact);
            }
        ])

The addContact()-method is provided by Symfonys CLI when creating ManyToMany relations, but can be added manually aswell (Docs, SymfonyCast).

  • Related