Doctrine 2 Many to Many Associations

with 2 Comments

This article will provide detailed instructions on how to setup Doctrine 2 Many to Many associations that automatically cascade the changes to their relationships.

The instructions below assume you have a basic understanding of PHP, Doctrine 2 ORM, and database table structures, but I will briefly go over what Doctrine 2 and many to many relationships are, and the basic creation of a many to many table relationship.

What is Doctrine 2?

Doctrine 2 is a powerful and easy to use Database Abstraction Layer (DBAL) and Object Relationship Mapping(ORM) library for writing PHP applications that utilize a Database Management System (DBMS). You can find more on Doctrine at their website.

One of the caveats of Doctrine 2 ORM is that certain features that one may expect to function out of the box simply don't. Instead the ORM relies on the developer of the application to program the desired functionality.

Such is the case with Doctrine Many to Many associations between their relationship tables not automatically cascading, unless the entity is written to do so or the developer writes the model to specifically execute the desired functionality for each entity object.

 

Top of page

 

What is a Many to Many Relationship?

A many to many relationship is used in data normalization in order to reduce table size by lowering the amount of duplicate data.

For example, if you have multiple movies and multiple categories and the movies and categories are associated with each other. Meaning a movie can have multiple categories and a category can have multiple movies.

Instead of creating a single table for movies and creating a new row for each category the movie is under, a separate table can be used to store the relationships between movie and category, otherwise known as a Junction Table.

many-to-many
Many to Many relationship using a junction table.

To learn more about many to many relationships in regards to associative entity mapping please see the wiki article.

 

Top of page

 

Creating a Many to Many Relationship.

Normally a Many to Many relationship involves 3 tables. Two tables to house the data, and a third table to house the relationship. The relationship table only contains 2 columns that are combined as a primary key. If the table that establishes the relationship contains additional columns, it is not a true many to many relationship and should be handled as a One to Many and Many to One relationship. I will be writing another article covering this functionality soon.

As in my example above we have movies and categories, these will be the two data tables. A third table is needed for the relationship, usually named by combining the two tables names, with the owning table first. For this example we will say that a movie is the owner to the categories.

The end result is a table filled with movies, a table filled with categories, and a table pointing to each table's identifiers in order to define the relationship like so.

movie
Table
 id title
1 Star Wars
2 Spaceballs
SQL

CREATE TABLE `movie` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`title` VARCHAR(50) NOT NULL,
PRIMARY KEY (`id`),
INDEX `title` (`title`)
);

INSERT INTO `movie` (`id`, `title`) VALUES (1, 'Star Wars'),(2, 'Spaceballs');

category
Table
id name
1 Action
2 Comedy
SQL

CREATE TABLE `category` (
`id` INT(11) NOT NULL AUTO_INCREMENT,
`name` VARCHAR(50) NOT NULL,
PRIMARY KEY (`id`),
INDEX `name` (`name`)
);

INSERT INTO `category` (`id`, `name`) VALUES (1, 'Action'),(2, 'Comedy');

movie_category
Table
movie category
1  1
2  1
2  2

 

This creates the relationships of Star Wars = Action and Spaceballs = Action+Comedy.

SQL

(use your database to establish foreign keys)


CREATE TABLE `movie_category` (
`movie` INT(11) NOT NULL,
`category` INT(11) NOT NULL,
PRIMARY KEY (`movie`, `category`),
INDEX `FK_movie_category_category` (`category`),
INDEX `FK_movie_category_movie` (`movie`),
CONSTRAINT `FK_movie_category_movie` FOREIGN KEY (`movie`) REFERENCES `movie` (`id`) ON UPDATE CASCADE ON DELETE CASCADE,
CONSTRAINT `FK_movie_category_category` FOREIGN KEY (`category`) REFERENCES `category` (`id`) ON UPDATE CASCADE ON DELETE CASCADE
)
INSERT INTO `movie_category` (`movie`, `category`) VALUES (1, 1),(2, 1),(2, 2);

 

Top of page

 

Define Doctrine 2 Many to Many Associations

Those of you who have been using Doctrine 2 have probably already created a table relationship using association mapping.

In order to define the relationships I will be creating the entities with PHP annotations. A few edits to the auto generated add/remove methods will ensure any changes to the relationships are carried over.

 

Movie

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 */
class Movie
{
    /**
     * @var int
     * @ORM\Id @ORM\Column(type="integer") @ORM\GeneratedValue
     */
    private $id;

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

    /**
     * @var \Doctrine\Common\Collections\ArrayCollection|Category[]
     * @ORM\ManyToMany(targetEntity="Category", inversedBy="movies")
     * @ORM\JoinTable(
     *  name="movie_category",
     *  joinColumns={
     *      @ORM\JoinColumn(name="movie", referencedColumnName="id")
     *  },
     *  inverseJoinColumns={
     *      @ORM\JoinColumn(name="category", referencedColumnName="id")
     *  }
     * )
     */
    private $categories;

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

    /**
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @return string
     */
    public function getTitle()
    {
        return $this->title;
    }

    /**
     * @param string $title
     * @return Movie
     */
    public function setName($title)
    {
        $this->title = $title;

        return $this;
    }

    /**
     * @return \Doctrine\Common\Collections\ArrayCollection|Category[]
     */
    public function getCategories()
    {
        return $this->categories;
    }

    /**
     * @param Category $category
     */
    public function removeCategory(Category $category)
    {
        if (false === $this->categories->contains($category)) {
            return;
        }
        $this->categories->removeElement($category);
        $category->removeMovie($this);
    }

    /**
     * @param Category $category
     */
    public function addCategory(Category $category)
    {
        if (true === $this->categories->contains($category)) {
            return;
        }
        $this->categories->add($category);
        $category->addMovie($this);
    }
}

 

Category

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 */
class Category
{

    /**
     * @var int
     * @ORM\Id @ORM\Column(type="integer") @ORM\GeneratedValue
     */
    private $id;

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

    /**
     * @var \Doctrine\Common\Collections\ArrayCollection|Movie[]
     * @ORM\ManyToMany(targetEntity="Movie", mappedBy="categories")
     */
    private $movies;

    public function __construct()
    {
        $this->movies = new \Doctrine\Common\Collections\ArrayCollection();
    }

    /**
     * @return int
     */
    public function getId()
    {
        return $this->id;
    }

    /** 
     * @return string 
     */
    public function getName()
    {
        return $this->name;
    }

    /**
     * @param string $name
     * @return Category
     */
    public function setName($name)
    {
        $this->name = $name;

        return $this;
    }

    /**
     * @return \Doctrine\Common\Collections\ArrayCollection|Movie[]
     */
    public function getMovies()
    {
        return $this->movies;
    }

    /**
     * @param Movie $movie
     */
    public function removeMovie(Movie $movie)
    {
        if (false === $this->movies->contains($movie)) {
            return;
        }
        $this->movies->removeElement($movie);
        $movie->removeCategory($this);
    }

    /**
     * @param Movie $movie
     */
    public function addMovie(Movie $movie)
    {
        if (true === $this->movies->contains($movie)) {
            return;
        }
        $this->movies->add($movie);
        $movie->addCategory($this);
    }
}

 

Top of page

 

Working with the relationships

Now that the entities are set to add or remove the relationships automatically you only need to call either one of the desired add or remove methods.

In the snippets below I use getReference() which reduces the work doctrine has to do by using a proxy instead of making a call to the database.
See more about reference proxies in the Doctrine Advanced Configuration.

 

Removing an associated relationship

Here I demonstrate the ability to remove a category from a movie or remove a movie from a category.

In either instance the Action category is removed from the movie Spaceballs.

Remove a category from a movie
$movie = $entityManager->getRepository('Path\To\Entities\Movie')->findOneByTitle('Spaceballs');
$category = $entityManager->getReference('Path\To\Entities\Category', 1);
$movie->removeCategory($category);
$entityManager->flush();

 

Remove a movie from a category
$category = $entityManager->getRepository('Path\To\Entities\Category')->findOneByName('Action');
$movie = $entityManager->getReference('Path\To\Entities\Movie', 2);
$category->removeMovie($movie);
$entityManager->flush();

 

Adding an associated relationship

Here I demonstrate the ability to add a category to a movie or add a movie to a category.

Add a category to a movie

Creates a new category Drama and Adds the category to the movie Star Wars

$category = new Category;
$category->setName('Drama');
$movie = $entityManager->getRepository('Path\To\Entities\Movie')->findOneByTitle('Star Wars');
$movie->addCategory($category);
$entityManager->persist($category);
$entityManager->flush();

 

Add a movie to a category

Creates a new movie Indiana Jones and adds the the new movie to the category Action

$movie = new Movie;
$movie->setTitle('Indiana Jones');
$category = $entityManager->getRepository('Path\To\Entities\Category')->findOneByName('Action');
$category->addMovie($movie);
$entityManager->persist($movie);
$entityManager->flush();

 

Top of page

 

These examples can be expanded to any number of other relationships. Such as Users and Roles, Orders and Items, and many to many others.

 

Have a question?
Leave comment below or send me a message by filling out the contact form.

2 Responses

  1. Marcel
    | Reply

    Thanks for the tutorial. This seems generally to be the recommended way to handle ManyToMany relations.

    But I really don’t like that adding a category to a movie leads to this call stack:

    Movie->addCategory() → Category->addMovie() → Movie->addCategory()

    It somehow smells bad to me as Movie->addCategory is called twice and I wonder if there is a more elegant way to do this.

    • Admin
      | Reply

      You could create an event listener to do the equivalent, but would cause a lot of extra overhead in comparison to using the method calls. As for the call stack loop, the condition on the ArrayCollection::contains($entity) does cause an extra method call before returning, but is a small performance impact compared to the alternatives.

      Ultimately all it does is help you from having to remember to call both methods manually within your models and ensures that the objects do not already exist within the entity collections.

Leave a Reply