Doctrine 2: Correct way to use a Many-To-Many association

When working in Symfony and using Doctrine 2 ORM  you sometimes need to use a many-to-many association between entities. This relationship means that a blog post can have multiple categories. And categories can be used in multiple blog posts.

As you can see we need a junction table for this. This junction tables links the blog posts with the correct categories. So how is the simplest and most correct way to do this in Doctrine 2 with entities?

Creating Entities

To get started we first create our BlogPost entity as seen below:

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * Class BlogPost
 * @ORM\Entity()
 * @ORM\Table(name="blog_post")
 * @package App\Entity
 */
class BlogPost
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     * @var int
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=155)
     * @var string
     */
    private $title;

    /**
     * @ORM\Column(type="text")
     * @var string
     */
    private $description;

    /**
     * @ORM\ManyToMany(targetEntity="Category", inversedBy="blogPosts")
     * @var Collection
     */
    private $categories;

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

    /* Getters and Setters */
}

So here we defined $categories as many-to-many association with targetEntity equal to Category.  Our $categories variable will be holding a collection of Category.  To be able to work with collections we first need to instantiate it in our constructor. This allows us to add or remove categories to a blog post. If we don’t instantiate this ArrayCollection on construction, then you will get errors.

On our Category entity we apply the same rules. This similar to our BlogPost entity.

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity(repositoryClass="App\Repository\CategoryRepository")
 * @ORM\Table(name="category")
 */
class Category
{
    /**
     * @ORM\Id
     * @ORM\GeneratedValue
     * @ORM\Column(type="integer")
     * @var int
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=155)
     * @var string
     */
    private $title;

    /**
     * @ORM\ManyToMany(targetEntity="BlogPost", mappedBy="categories")
     * @var Collection
     */
    private $blogPosts;

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

    /* Getters and Setters */
}

Getters and Setters

So now that we defined our properties for our entities. And also made sure we have a collection when the entities get constructed. We can now implement our getter and setter methods for $categories and $blogPosts.

Getting a collection

So first we are going to create some basic get methods:

/**
 * @return Collection
 */
public function getCategories(): Collection
{
    return $this->categories;
}
/**
 * @return Collection
 */
public function getBlogPosts(): Collection
{
    return $this->blogPosts;
}

Both get methods returns Doctrine\Common\Collections\Collection. Not ArrayCollection like we created in our constructors. If you try to return ArrayCollection then you will get the following error:

Type error: Return value of App\Entity\BlogPost::getCategories() must be an instance of Doctrine\Common\Collections\ArrayCollection, instance of Doctrine\ORM\PersistentCollection returned

So why is this? Well if we look deeper in the Doctrine classes we can find the following:

  • ArrayCollection is a class that implements the interface Collection:
    class ArrayCollection implements Collection, Selectable
  • PersistentCollection is class that extends AbstractLazyCollection:
    final class PersistentCollection extends AbstractLazyCollection implements Selectable
  • but AbstractLazyCollection implements Collection:
    abstract class AbstractLazyCollection implements Collection

Collection is an interface and we should use that instead. Use your interfaces and not your implementations! Now it does not matter if the actual result is an ArrayCollection or a PersistentCollection. They are all implementations of the interface. In our entities we work with an array collection but Doctrine uses a persistent collection to lazy load our properties only when required. This is a mechanic of doctrine to improve performance and memory usage.

Adding to a collection

Instead of using a set method, we are going to create methods for adding and removing categories or blog posts to our collections.

/**
 * @param Category $category
 */
public function addCategory(Category $category): void
{
    // First we check if we already have this category in our collection
    if ($this->categories->contains($category)){
        // Do nothing if its already part of our collection
        return;
    }

    // Add category to our array collection
    $this->categories->add($category);

    // We also add this blog post to the category. This way both entities are 'linked' together.
    // In a manyToMany relationship both entities need to know that they are linked together.
    $category->addBlogPost($this);
}
/**
 * @param BlogPost $blogPost
 */
public function addBlogPost(BlogPost $blogPost): void
{
    // First we check if we already have this blog post in our collection
    if ($this->blogPosts->contains($blogPost)) {
        // Do nothing if its already part of our collection
        return;
    }

    // Add blog post to our array collection
    $this->blogPosts->add($blogPost);

    // We also add this category to the blog post. This way both entities are 'linked' together.
    // In a manyToMany relationship both entities need to know that they are linked together.
    $blogPost->addCategory($this);
}

As you can see we first check if the entity we add to the collection is already part of that collection. If not we add this to our collection. We also need to make sure that both entities are linked together and have each other in both collections. Its important that both entities always have the correct data at any time.

Removing from a collection

So if we have a method to add objects to a collection, then we should also have one to remove them.

/**
 * @param Category $category
 */
public function removeCategory(Category $category): void
{
    // If the category does not exist in the collection, then we don't need to do anything
    if (!$this->categories->contains($category)) {
        return;
    }

    // Remove category from the collection
    $this->categories->removeElement($category);

    // Also remove this from the blog post collection of the category
    $category->removeBlogPost($this);
}
/**
 * @param BlogPost $blogPost
 */
public function removeBlogPost(BlogPost $blogPost): void
{
    // If the blog post does not exist in the collection, then we don't need to do anything
    if (!$this->blogPosts->contains($blogPost)) {
        return;
    }

    // Remove blog post from the collection
    $this->blogPosts->removeElement($blogPost);

    // Also remove this from the category collection of the blog post
    $blogPost->removeCategory($this);
}

So this is similar to adding to a collection. The difference is that we first check if the object does exist in the collection before removing it. We can’t remove something that does not exist. Then we also make sure that this change is applied to both entities.

Beware of many-to-many

In some cases you don’t need a many-to-many association. When you want other data in your junction table, the link between both entities. Then you should use a one-to-many/many-to-one relationship. You should create a third entity that is the link between both entities. This way you can add multiple columns to the join table. Your join table is an entity. It is part of your business logic if it does more then linking 2 entities together.

You should think this trough before creating a many-to-many relationship. Here an example of a many-to-many that was changed to a one-to-many/many-to-one because we needed to also know when the user started working on the company and for which salary.

Conclusion

Today we learned one correct way to create a many-to-many association. There are more solutions to this problem.  Anyone that has other solutions are free to discuss them here. And  we also learned a bit about collections. How they work in the background of Doctrine. We should also be carefull to to abuse many-to-many associations and maybe think about it. Maybe we should use a one-to-many/many-to-one association?As always think before you code!