Example of a Doctrine 2.x many-to-many association class

by Niki on February 2, 2013

At some point while reading the doctrine 2.x documentation about association mapping there is a mention about association classes at the bottom of the section about unidirectional many-to-many associations.

Why are many-to-many associations less common? Because frequently you want to associate additional attributes with an association, in which case you introduce an association class. Consequently, the direct many-to-many association disappears and is replaced by one-to-many/many-to-one associations between the 3 participating classes.

http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/association-mapping.html#many-to-many-unidirectional

So while these associations are less common, no real example is given of an implementation of 2 entities and an association class.

In the next section I will try to give an example implementation of such a construct.

We will use the easy example of a recipe website that needs to associate users to recipes. So each user can be associated with multiple recipes and each recipe can be associated with multiple users. The catch is that the association between the user and the recipe must indicate if a user owns a recipe or if a user likes another user’s recipe.

Let’s start with the user Entity. I like using FOSUserBundle for my user implementations so that is what I will be using in this example.

User Entity

namespace NVC\UserBundle\Entity;

use FOS\UserBundle\Entity\User as BaseUser;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity
 * @ORM\Table(name="User")
 */
class User extends BaseUser
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\OneToMany(targetEntity="UserRecipeAssociation", mappedBy="user")
     */
    protected $user_recipe_associations;

    public function __construct()
    {
        parent::__construct();

        $this->user_recipe_associations = new \Doctrine\Common\Collections\ArrayCollection();
    }

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

    /**
     * Add user_recipe_associations
     *
     * @param \NVC\UserBundle\Entity\UserRecipeAssociation $userRecipeAssociations
     * @return User
     */
    public function addUserRecipeAssociation(\NVC\UserBundle\Entity\UserRecipeAssociation $userRecipeAssociations)
    {
        $this->user_recipe_associations[] = $userRecipeAssociations;

        return $this;
    }

    /**
     * Remove user_recipe_associations
     *
     * @param \NVC\UserBundle\Entity\UserRecipeAssociation $userRecipeAssociations
     */
    public function removeUserRecipeAssociation(\NVC\UserBundle\Entity\UserRecipeAssociation $userRecipeAssociations)
    {
        $this->user_recipe_associations->removeElement($userRecipeAssociations);
    }

    /**
     * Get user_recipe_associations
     *
     * @return \Doctrine\Common\Collections\Collection
     */
    public function getUserRecipeAssociations()
    {
        return $this->user_recipe_associations;
    }
}

We have introduced a member with a target entity of the type UserRecipeAssociation. This type will be explained in the last section of this post

    /**
     * @ORM\OneToMany(targetEntity="UserRecipeAssociation", mappedBy="user")
     */
    protected $user_recipe_associations;

Now the Recipe entity. For the sake of clarity we keep it simple and implement just four fields: The unique id, a name, a description and an integer for the cooking time.

Recipe Entity

namespace NVC\RecipeBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * Recipe
 *
 * @ORM\Table()
 * @ORM\Entity(repositoryClass="NVC\RecipeBundle\Entity\RecipeRepository")
 */
class Recipe
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

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

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

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

    /**
     * @ORM\OneToMany(targetEntity="NVC\UserBundle\Entity\UserRecipeAssociation", mappedBy="recipe")
     */
    protected $user_recipe_associations;

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

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

        return $this;
    }

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

    /**
     * Set description
     *
     * @param string $description
     * @return Recipe
     */
    public function setDescription($description)
    {
        $this->description = $description;

        return $this;
    }

    /**
     * Get description
     *
     * @return string
     */
    public function getDescription()
    {
        return $this->description;
    }

    /**
     * Set cooking_time
     *
     * @param integer $cookingTime
     * @return Recipe
     */
    public function setCookingTime($cookingTime)
    {
        $this->cooking_time = $cookingTime;

        return $this;
    }

    /**
     * Get cooking_time
     *
     * @return integer
     */
    public function getCookingTime()
    {
        return $this->cooking_time;
    }

    /**
     * Constructor
     */
    public function __construct()
    {
        $this->user_recipe_associations = new \Doctrine\Common\Collections\ArrayCollection();
    }

    /**
     * Add user_recipe_associations
     *
     * @param \NVC\UserBundle\Entity\UserRecipeAssociation $userRecipeAssociations
     * @return Recipe
     */
    public function addUserRecipeAssociation(\NVC\UserBundle\Entity\UserRecipeAssociation $userRecipeAssociations)
    {
        $this->user_recipe_associations[] = $userRecipeAssociations;

        return $this;
    }

    /**
     * Remove user_recipe_associations
     *
     * @param \NVC\UserBundle\Entity\UserRecipeAssociation $userRecipeAssociations
     */
    public function removeUserRecipeAssociation(\NVC\UserBundle\Entity\UserRecipeAssociation $userRecipeAssociations)
    {
        $this->user_recipe_associations->removeElement($userRecipeAssociations);
    }

    /**
     * Get user_recipe_associations
     *
     * @return \Doctrine\Common\Collections\Collection
     */
    public function getUserRecipeAssociations()
    {
        return $this->user_recipe_associations;
    }
}

Now what we want to do is introduce a third entity class that will hold the associations between the User entity and the Recipe entity.This entity will be called ‘UserRecipeAssociation’ and reside in the NVC\UserBundle\Entity namespace.

UserRecipeAssociation Entity

namespace NVC\UserBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * UserRecipeAssociation
 *
 * @ORM\Table()
 * @ORM\Entity(repositoryClass="NVC\UserBundle\Entity\UserRecipeAssociationRepository")
 */
class UserRecipeAssociation
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;
    /**
     **/

    /**
     *
     * @ORM\ManyToOne(targetEntity="User", inversedBy="user_recipe_associations")
     * @ORM\JoinColumn(name="user_id", referencedColumnName="id")
     *
     */
    private $user;

    /**
     *
     * @ORM\ManyToOne(targetEntity="NVC\RecipeBundle\Entity\Recipe", inversedBy="user_recipe_associations")
     * @ORM\JoinColumn(name="recipe_id", referencedColumnName="id")
     */
    private $recipe;

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

    /**
     * Set user
     *
     * @param \NVC\UserBundle\Entity\User $user
     * @return UserRecipeAssociation
     */
    public function setUser(\NVC\UserBundle\Entity\User $user = null)
    {
        $this->user = $user;

        return $this;
    }

    /**
     * Get user
     *
     * @return \NVC\UserBundle\Entity\User
     */
    public function getUser()
    {
        return $this->user;
    }

    /**
     * Set recipe
     *
     * @param \NVC\RecipeBundle\Entity\Recipe $recipe
     * @return UserRecipeAssociation
     */
    public function setRecipe(\NVC\RecipeBundle\Entity\Recipe $recipe = null)
    {
        $this->recipe = $recipe;

        return $this;
    }

    /**
     * Get recipe
     *
     * @return \NVC\RecipeBundle\Entity\Recipe
     */
    public function getRecipe()
    {
        return $this->recipe;
    }
}

With these three code examples you should be able to produce your own association classes.

{ 9 comments… read them below or add one }

Andrey March 17, 2013 at 6:02 pm

Tnx

Reply

Andrey March 17, 2013 at 6:03 pm

Please, add YML-mapping example!

Reply

Far April 19, 2013 at 6:06 am

Yes, please, some yaml mapping example.

Reply

Stefan July 17, 2013 at 5:27 pm

“The catch is that the association between the user and the recipe must indicate if a user owns a recipe or if a user likes another user’s recipe. ”

Where and how is this indicated? I can’t see that relation being described somewhere in the code.

Reply

Pablo August 5, 2013 at 4:24 pm

cool thanks

Reply

Wes August 21, 2013 at 5:26 am

Niki, this is seriously such a great post and almost identical to what I’ve been searching hours for! I’m still missing something though — could you show how you would use this in a Controller to accomplish something like this:

$entities = $em->getRepository(“NVCUserBundle:User”)->findAll();

//loop through all users and print something interesting about their recipe association
foreach($entities as $user) {
foreach($user->getUserRecipeAssociations() as $assoc) {
echo $assoc->getId();
}
}

I keep getting errors like this:
Notice: Undefined index: user in …/vendor/doctrine/orm/lib/Doctrine/ORM/Persisters/BasicEntityPersister.php on line 1575
Warning: Invalid argument supplied for foreach() in …/vendor/doctrine/orm/lib/Doctrine/ORM/Persisters/BasicEntityPersister.php on line 1580

The “user” it seems to indicate is from my mappedBy=”user” statement in the User Entity…I think.

By the way I’m trying to run this from a new public function in an extended RegistrationController of FOSUserBundle. The only difference in my setup is that my equivalent of your UserRecipeAssociation entity is not in my UserBundle but in my RecipeBundle…however I let doctrine generate all the entities and they *seem* good to me…obviously something is screwed up.

Thanks so much for any additional ideas!

Reply

Jhenrry Mamani October 23, 2013 at 10:52 pm

Thank you for this post, after google it for hours I found this useful information, but what am I supposed to overried the controller? I extended the
“class RegistrationController extends \FOS\UserBundle\Controller\RegistrationController”
but in its “registerAction” I have no idea how I can persist the data in the third entity UserRecipeAssociation

Thanks in advance!

Reply

Ben October 25, 2013 at 3:01 am

This has been invaluable. I have been experimenting with creating two entities and their association in one operation

I have got this to work:

$user = new User();
$recipe = new Recipe();
$userRecipe = new UserRecipe();
$userRecipe->setUser( $user );
$userRecipe->setRecipe( $recipe);

$em->persist( $user );
$em->flush;
$em->persist( $recipe );
$em->flush;
$em->persist( $userRecipe);
$em->flush;

The other method is using addUserRecipeAssociation() which is slightly less verbose:

$user = new User();
$recipe = new Recipe();
$userRecipe = new UserRecipe();
$userRecipe->setUser( $user );
$userRecipe->setRecipe( $recipe);

$user->addUserRecipeAssociation( $userRecipe );
$em->persist( $user );
$em->flush;

(By the way, I would wrap these atomic operations in a $em->getConnection()->beginTransaction() with rollback option)

Presumably the author would recommend the second approach and this is what was intended. I would be grateful for any feedback or thoughts.

Reply

Suiko6272 October 28, 2013 at 9:12 am

@Ben
One big thing, don’t be doing persist, flush, persist, flush. Do all your persists, then a single flush. Flush saves the changes to your EM to the database, so you should always try for 1 bulk upload.

You should have your $em (or any database in general) cascade the persist & remove operations of a link table (associations). Essentially if you say $em->persist($userRecipe) it needs to check stored objects to see if they need to be persisted (you might have added a new user but used Recipe->ID:5), you also would need to ensure user & recipe aren’t null. Same thing with removing say a User->ID:4, you’d have to cascade into your association table and remove all rows with user_id == 4

I’m still learning Doctrine & zend myself but if you check Doctrine’s reference doc there’s a @ORM command for exactly this usage. http://docs.doctrine-project.org/en/latest/reference/working-with-associations.html
@OneToMany(target…, mappedBy….., cascade={“persist”, “remove”))

Reply

Leave a Comment

Previous post: