Buy

ManyToMany: The Inverse Side of the Relationship

Our goal is clear: list all of the genuses studied by this User.

The Owning vs Inverse Side of a Relation

Back in our Doctrine Relations tutorial, we learned that every relationship has two different sides: a mapping, or owning side, and an inverse side. In that course, we added a GenusNote entity and gave it a ManyToOne relationship to Genus:

101 lines src/AppBundle/Entity/GenusNote.php
... lines 1 - 10
class GenusNote
{
... lines 13 - 39
/**
* @ORM\ManyToOne(targetEntity="Genus", inversedBy="notes")
* @ORM\JoinColumn(nullable=false)
*/
private $genus;
... lines 45 - 99
}

This is the owning side, and it's the only one that we actually needed to create.

If you look in Genus, we also mapped the other side of this relationship: a OneToMany back to GenusNote:

189 lines src/AppBundle/Entity/Genus.php
... lines 1 - 14
class Genus
{
... lines 17 - 65
/**
* @ORM\OneToMany(targetEntity="GenusNote", mappedBy="genus")
* @ORM\OrderBy({"createdAt" = "DESC"})
*/
private $notes;
... lines 71 - 187
}

This is the inverse side of the relationship, and it's optional. When we mapped the inverse side, it caused no changes to our database structure. We added it purely for convenience, because we decided it sure would be fancy and nice if we could say $genus->getNotes() to automagically fetch all the GenusNotes for this Genus.

With a ManyToOne relationship, we don't choose which side is which: the ManyToOne side is always the required, owning side. And that makes sense, it's the table that holds the foreign key column, i.e. GenusNote has a genus_id column.

Owning and Inverse in ManyToMany

We can also look at our ManyToMany relationship in two different directions. If I have a Genus object, I can say:

Hello fine sir: please give me all Users related to this Genus.

But if I have a User object, I should also be able to say the opposite:

Good evening madame: I would like all Genuses related to this User.

The tricky thing about a ManyToMany relationship is that you get to choose which side is the owning side and which is the inverse side. And, I hate choices! The choice does have consequences.... but don't worry about that - we'll learn why soon.

Mapping the Inverse Side

Since we only have one side of the relationship mapped now, it's the owning side. To map the inverse side, open User and add a new property: $studiedGenuses. This will also be a ManyToMany with targetEntity set to Genus. But also add mappedBy="genusScientists:

223 lines src/AppBundle/Entity/User.php
... lines 1 - 16
class User implements UserInterface
{
... lines 19 - 77
/**
* @ORM\ManyToMany(targetEntity="Genus", mappedBy="genusScientists")
*/
private $studiedGenuses;
... lines 82 - 221
}

That refers to the property inside of Genus:

192 lines src/AppBundle/Entity/Genus.php
... lines 1 - 14
class Genus
{
... lines 17 - 71
/**
* @ORM\ManyToMany(targetEntity="User")
* @ORM\JoinTable(name="genus_scientist")
*/
private $genusScientists;
... lines 77 - 190
}

Now, on that property, add inversedBy="studiedGenuses, which points back to the property we just added in User:

192 lines src/AppBundle/Entity/Genus.php
... lines 1 - 14
class Genus
{
... lines 17 - 71
/**
* @ORM\ManyToMany(targetEntity="User", inversedBy="studiedGenuses")
* @ORM\JoinTable(name="genus_scientist")
*/
private $genusScientists;
... lines 77 - 190
}

When you map both sides of a ManyToMany relationship, this mappedBy and inversedBy configuration is how you tell Doctrine which side is which. We don't really know why that's important yet, but we will soon.

Back in User, remember that whenever you have a relationship that holds a collection of objects, like a collection of "studied genuses", you need to add a __construct function and initialize that to a new ArrayCollection():

223 lines src/AppBundle/Entity/User.php
... lines 1 - 4
use Doctrine\Common\Collections\ArrayCollection;
... lines 6 - 16
class User implements UserInterface
{
... lines 19 - 82
public function __construct()
{
$this->studiedGenuses = new ArrayCollection();
}
... lines 87 - 221
}

Finally, since we'll want to be able to access these studiedGenuses, go to the bottom of User and add a new public function getStudiedGenuses(). Return that property inside. And of course, we love PHP doc, so add @return ArrayCollection|Genus[]:

223 lines src/AppBundle/Entity/User.php
... lines 1 - 4
use Doctrine\Common\Collections\ArrayCollection;
... lines 6 - 16
class User implements UserInterface
{
... lines 19 - 214
/**
* @return ArrayCollection|Genus[]
*/
public function getStudiedGenuses()
{
return $this->studiedGenuses;
}
}

Using the Inverse Side

And just by adding this new property, we are - as I love to say - dangerous.

Head into the user/show.html.twig template that renders the page we're looking at right now. Add a column on the right side of the page, a little "Genuses Studied" header, then a ul. To loop over all of the genuses that this user is studying, just say for genusStudied in user.studiedGenuses. Don't forget the endfor:

56 lines app/Resources/views/user/show.html.twig
... lines 1 - 2
{% block body %}
<div class="container">
<div class="row">
... lines 6 - 38
<div class="col-xs-4">
<h3>Genus Studied</h3>
<ul class="list-group">
{% for genusStudied in user.studiedGenuses %}
... lines 43 - 49
{% endfor %}
</ul>
</div>
</div>
</div>
{% endblock %}

Inside, add our favorite list-group-item and then a link. Link this back to the genus_show route, passing slug set to genusStudied.slug. Print out genusStudied.name:

56 lines app/Resources/views/user/show.html.twig
... lines 1 - 2
{% block body %}
<div class="container">
<div class="row">
... lines 6 - 38
<div class="col-xs-4">
<h3>Genus Studied</h3>
<ul class="list-group">
{% for genusStudied in user.studiedGenuses %}
<li class="list-group-item">
<a href="{{ path('genus_show', {
'slug': genusStudied.slug
}) }}">
{{ genusStudied.name }}
</a>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endblock %}

But will it blend? I mean, will it work? Refresh!

Hot diggity dog! There are the three genuses that this User studies. We did nothing to deserve this nice treatment: Doctrine is doing all of the query work for us.

In fact, click the database icon on the web debug toolbar to see what the query looks like. When we access the property, Doctrine does a SELECT from genus with an INNER JOIN to genus_scientist where genus_scientist.user_id equals this User's id: 11. That's perfect! Thanks Obama!

Ordering the Collection

The only bummer is that we can't control the order of the genuses. What if we want to list them alphabetically? We can't - we would instead need to make a custom query for the genuses in the controller, and pass them into the template.

What? Just kidding! In User, add another annotation: @ORM\OrderBy({"name" = "ASC"):

224 lines src/AppBundle/Entity/User.php
... lines 1 - 16
class User implements UserInterface
{
... lines 19 - 77
/**
* @ORM\ManyToMany(targetEntity="Genus", mappedBy="genusScientists")
* @ORM\OrderBy({"name" = "ASC"})
*/
private $studiedGenuses;
... lines 83 - 222
}

Refresh that!

If you didn't see a difference, you can double-check the query to prove it. Boom! There's our new ORDER BY. Later, I'll show you how you can mess with the query made for collections even more via Doctrine Criteria.

But up next, the last missing link: what if a User stops studying a Genus? How can we remove that link?

Leave a comment!

  • 2017-10-27 James Davison

    I seem to get a problem with my self referenced many to many relation in between products.
    I changed the relation to a many to many unidirectional which works on the frontend but do not know how to incorporated to the form to add or delete related products to a product.
    The error coming up is:
    Entities passed to the choice field must be managed. Maybe persist them in the entity manager?

  • 2017-10-27 James Davison

    Just sent you another message on my github, the related products don't seem to work when passing the object to the form. Could you help me please?

  • 2017-10-20 Diego Aguiar

    Hey Mohammad Althayabeh

    I can think in two options, but to know which one is better for you, deppends on how are you planning to work with your objects

    1) set a self referenced ManyToMany relationship (is like you said), you can get a better idea of how to achieve it here: http://docs.doctrine-projec... but I don't think you need to make it bidirectional

    2) Create a new entity called something like `PrerequisiteCourseGroup` which will hold the Course and a list of (another new entity) `PrerequesiteCourse`, this entity will only have the reference of a Course (the prerequesite course)
    So, in this case, given a Course, you can query for a PrerequisiteCourseGroup object and then ask him to give you the list of all prerequisite courses

    Second approach is kind of confusing but it's nice if you don't want to alter your current course schema

    Cheers!

  • 2017-10-20 Mohammad Althayabeh

    Hi,

    How would you approach ManyToMany relationship on the same entity. For example, if i am having entity Course and would like to have field represent the prerequisites courses of that Course which are from the same entity? what is the best way to implement this?

    Thank you, much appreciated.

  • 2017-10-19 James Davison
  • 2017-10-19 Diego Aguiar

    Hey James, I could see your invitation, but for some reason I cannot see your organization into my list =/
    What's the name of your org/repo?

  • 2017-10-19 James Davison

    I have sent you an invite.

    Thanks again

  • 2017-10-19 Diego Aguiar

    sure no problem, find me as Larzuk91

  • 2017-10-19 James Davison

    Let me add you as a collaborator on Github if this is alright with you.

  • 2017-10-19 Diego Aguiar

    If you dump `$category->getProducts()` and `$formCategory['products']->getData()`
    Are there any differences?

    Do you have this code in any public repository? I would like to check it in more detail

  • 2017-10-19 James Davison

    Hi! Thanks for the quick answer,
    it is a many to many and products or categories can be alone.

    The entities code is in my first message.

    Thanks again!

  • 2017-10-17 Diego Aguiar

    That's great!
    I need to know a bit more about the relationship between category and product, can you have products without a category?

  • 2017-10-17 James Davison

    Thanks for the tip Diego! Added cascade and it does the trick. If I don't remove all products from category first then if I just delete products from the category it does not work. Maybe do you know of a quick way to identify the products which are not present anymore which need to be deleted; something to check the difference.

  • 2017-10-16 Diego Aguiar

    Hey James Davison
    Have you tried configuring "cascade" on persist? or you have a special reason to don't do it?
    Another question, why are you removing all products from the category?

  • 2017-10-16 James Davison

    Thanks for the fast reply! You guys are awesome!
    I did as you said so I am updating the products and because I keep both side synced, I am also updating the categories. So I tried to persist products then persist categories then flush but I still get the following error:

    A new entity was
    found through the relationship 'AppBundle\Entity\Product#categories'
    that was not configured to cascade persist operations for entity:
    AppBundle\Entity\Category@000000006d8a552300000001007fe7fa. To solve
    this issue: Either explicitly call EntityManager#persist() on this
    unknown entity or configure cascade persist this association in the
    mapping for example @ManyToOne(..,cascade={"persist"}). If you cannot
    find out which entity causes the problem implement
    'AppBundle\Entity\Category#__toString()' to get a clue.

    Here is my code in the controller:

    if ($formCategory->isSubmitted() && $formCategory->isValid()) {

    // Save category changes
    $category->setName($formCategory['name']->getData());
    $category->setSub($formCategory['sub']->getData());
    $category->setSlug($formCategory['slug']->getData());
    $category->setMetaTitle($formCategory['metaTitle']->getData());
    $category->setMetaKeywords($formCategory['metaKeywords']->getData());
    $category->setMetaDescription($formCategory['metaDescription']->getData());
    $category->setDescription($formCategory['description']->getData());

    if ($formCategory['image']->getData()){

    $imageSaver = new ImageService();

    $imageName = $imageSaver->imageSaveAction(
    $formCategory['image']->getData(),
    $formCategory['name']->getData(),
    $this->getParameter('images_directory')
    );

    $category->setImageName($imageName);
    }

    foreach ($category->getProducts() as $product) {
    $category->removeProduct($product);
    }

    if (count($formCategory['products']->getData())){

    foreach ($formCategory['products']->getData() as $product) {
    $category->addProduct($product);
    $em->persist($product);
    }

    }

    $em->persist($category);
    $em->flush();

    $this->addFlash('success', 'Category updated');

    return $this->redirectToRoute('categories_view', array('id' => $category->getId()));

    }

  • 2017-10-16 Victor Bocharsky

    Hey James,

    Looks like you need to call persist() on Category entity which you add to collection. Don't forget that Doctrine requires calling persist() on new entities before calling flush().

    Cheers!

  • 2017-10-13 James Davison

    Hi!

    I don't really know what is happening but I am having a problem with the inverse side of the relationship. I have categories and products like below. The problem is that it works when I save products in a category but not the inverse. I tried in my controller to $em->flush() without a parameter but it tells me:

    A new entity was found through the relationship 'AppBundle\Entity\Product#categories' that was not configured to cascade persist operations for entity:
    AppBundle\Entity\Category@00000000565496d3000000017d6c6077. To solve this issue: Either explicitly call EntityManager#persist() on this unknown entity or configure cascade persist this association in the mapping for example @ManyToOne(..,cascade={"persist"}). If you cannot find out which entity causes the problem implement 'AppBundle\Entity\Category#__toString()' to get a clue".



    /**
    * @ORM\ManyToMany(targetEntity="Category", mappedBy="products")
    */
    private $categories;

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

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

    public function addCategory(Category $category)
    {
    if ($this->categories->contains($category)) {
    return;
    }

    $this->categories[] = $category;

    // not needed for persistence, just keeping both sides in sync
    $category->addProduct($this);
    }

    public function removeCategory(Category $category)
    {
    if (!$this->categories->contains($category)) {
    return;
    }

    $this->categories->removeElement($category);

    // not needed for persistence, just keeping both sides in sync
    $category->removeProduct($this);
    }

    and


    /**
    * @ORM\ManyToMany(targetEntity="Product", inversedBy="categories", fetch="EXTRA_LAZY")
    * @ORM\JoinTable(name="categories_products")
    */
    private $products;

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

    /**
    * @return ArrayCollection|Product[]
    */
    public function getProducts()
    {
    return $this->products;
    }

    public function addProduct(Product $product)
    {
    if ($this->products->contains($product)) {
    return;
    }

    $this->products[] = $product;

    // not needed for persistence, just keeping both sides in sync
    $product->addCategory($this);
    }

    public function removeProduct(Product $product)
    {
    if (!$this->products->contains($product)) {
    return;
    }

    $this->products->removeElement($product);

    // not needed for persistence, just keeping both sides in sync
    $product->removeCategory($this);
    }

    Let me know if you need more details to help me to solve this problem.

    Thanks loads guys!

    James