Fix Symfony ManyToMany relationships when using the FormBuilder
14 Nov 2024 • Vincent BATHELIERIt’s crucial to consider the relationships between your entities from the very beginning of your project. Failure to do so can result in substantial time wastage later on due to significant refactoring of your code base.
You might have noticed, after generating the CRUD operations for your entities using the make:crud
command that ManyToMany
relationships in forms don’t work as expected: it is not bidirectional in write operations. Let me illustrate this with a concrete example and explain how to resolve it.
A Basic ManyToMany Relationship
Let’s say we have the following entities. An Article
have a title, a content and an author. A Tag
have a name and nothing more.
# src/Entity/Article.phpnamespace App\Entity;use App\Repository\ArticleRepository;use Doctrine\DBAL\Types\Types;use Doctrine\ORM\Mapping as ORM;#[ORM\Entity(repositoryClass: ArticleRepository::class)]class Article{ #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] private ?string $title = null; #[ORM\Column(type: Types::TEXT)] private ?string $content = null; // getters and setters}
# src/Entity/Tag.php// ...#[ORM\Entity(repositoryClass: TagRepository::class)]class Tag{ #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column] private ?int $id = null; #[ORM\Column(length: 255)] private ?string $label = null; // getters and setters}
An article can have multiple tags and tags can be associated to multiple articles. Therefore, we need to setup a ManyToMany
relationship.
# src/Entity/Article.php#[ORM\Entity(repositoryClass: ArticleRepository::class)]class Article{ // previous properties /** * @var Collection<int, Tag> */ #[ORM\ManyToMany(targetEntity: Tag::class, inversedBy: 'articles')] private Collection $tags; // getters and setters}
# src/Entity/Tag.php// ...#[ORM\Entity(repositoryClass: TagRepository::class)]class Tag{ // previous properties /** * @var Collection<int, Article> */ #[ORM\ManyToMany(targetEntity: Article::class, mappedBy: 'tags')] private Collection $articles; // getters and setters}
Our entities are ready, we can now generate the CRUD operations using Symfony commands.
symfony console make:crud Articlesymfony console make:crud Tag
Don’t forget to apply your changes to the database schema.
symfony console make:migrationsymfony console doctrine:migrations:migrate
If you’re familiar with Symfony, you can test the application by navigating to the relevant endpoints. /article
and /tag
Try creating new tags and articles without selecting relationships. By default, you can’t. However, you’ve noticed that you need to select at least a tag to create an article and at least an article to create a tag.
That’s because those relationships are mandatory in their respective FormTypes. It’s easy to remove them; follow these steps. We’ll also change the displayed label in the select
input.
# src/Form/TagType.php$builder ->add('label') ->add('articles', EntityType::class, [ 'class' => Article::class, 'choice_label' => 'id', 'choice_label' => 'title', 'multiple' => true, 'required' => false, ]);
# src/Form/ArticleType.php$builder ->add('title') ->add('content') ->add('tags', EntityType::class, [ 'class' => Tag::class, 'choice_label' => 'id', 'choice_label' => 'label', 'multiple' => true, 'required' => false, ]);
At this point, we can create new articles and tags without manually selecting relationships between them. Give it a try. Does it work? Of course, it does.
Now, let’s edit one of your existing tags to select an article. Click the save button and then go to the edit form. You’ll notice that the previously selected article is no longer selected. That’s precisely the issue I wanted to discuss today.
The Problem: Unidirectional Form Handling
Selecting an article from a tag does not work. However, the opposite path does. That means that our ManyToMany
relationship is bidirectional in read operations but unidirectional in write operations.
By default, as stated in this StackOverflow answer, Symfony’s form handling system is adding the relationship by reference, using the methods of ArrayCollection
. Because there is an owning and an inverse side in ManyToMany
, adding an entity to the inverse side ArrayCollection
will have no effect.
The solution
Adding the relationships by reference is not what we want, so we need to tell Symfony not to do so.
When we look closer to our entities Article::addTag
and Tag::addArticle
methods, we can see that the inverse side (the Tag entity) is calling Article::addTag
to add itself in the article’s ArrayCollection
after adding the article in it’s own ArrayCollection
.
# src/Entity/Tag.phppublic function addArticle(Article $article): static{ if (!$this->articles->contains($article)) { $this->articles->add($article); $article->addTag($this); } return $this;}
Thankfully, the FormBuilder has an option to tell the form handling system to use our custom (but auto-generated) add
/remove
methods. This option is called by_reference
and needs to by set to false
.
# src/Form/TagType.php$builder ->add('label') ->add('articles', EntityType::class, [ 'class' => Article::class, 'choice_label' => 'title', 'multiple' => true, 'required' => false, 'by_reference' => false, ]);
And, that’s all, you fixed your forms!
Conclusion
Symfony’s FormBuilder struggles with bidirectional ManyToMany relationships in write operations. To fix this, set the by_reference
option to false
in the EntityType form field. This instructs Symfony to use the custom add
/remove
methods generated for the entities, ensuring bidirectional relationships in both read and write operations.