Relating RedditPost with RedditAuthor - OneToMany, ManyToOne


In this video we are going to create a bi-directional One To Many / Many To One relationship between our RedditPost and RedditAuthor entities. There are quite a lot of new terms and terminology used when working with Doctrine entity relationships, so rather than try and cover all of them then look at the code, instead, we will cover the code then relate it back to Doctrine's terminology.

I recommend you keep the Doctrine Association Mapping page open whenever you are working with Doctrine entity relationships as this reference is invaluable. Likewise, an understanding of Owning and Inverse sides of Doctrine relationships will greatly help your ability to learn this subject faster.

I cannot stress enough how important - and confusing - these terms are to your comprehension of Doctrine relationships, and whilst I don't like to wholeheartedly copy/paste the manual, in this instance, this is too important not too:

Owning Side and Inverse Side

When mapping bidirectional associations it is important to understand the concept of the owning and inverse sides. The following general rules apply:

  • Relationships may be bidirectional or unidirectional
  • A bidirectional relationship has both an owning side and an inverse side
  • A unidirectional relationship only has an owning side
  • Doctrine will only check the owning side of an association for changes

Bidrectional Associations

The following rules apply to bidirectional associations:

  • The inverse side has to use the mappedBy attribute of the OneToOne, OneToMany, or ManyToMany mapping declaration. The mappedBy attribute contains the name of the association-field on the owning side
  • The owning side has to use the inversedBy attribute of the OneToOne, ManyToOne, or ManyToMany mapping declaration. The inversedBy attribute contains the name of the association-field on the inverse-side
  • ManyToOne is always the owning side of a bidirectional association
  • OneToMany is always the inverse side of a bidirectional association
  • The owning side of a OneToOne association is the entity with the table containing the foreign key
  • You can pick the owning side of a many-to-many association yourself

This is the one subject I get more questions on than any other.

Aside from repeatedly typing out these associations in a code-kata fashion, I cannot think of any other way of learning what these mean than by simply reading these rules over and over until they commit to your memory.

Doctrine Unidirectional vs Bidirectional

What is the difference between Doctrine's unidirectional and bidirectional relationships?

This all depends on whether one or both entities in the relationship know about each other.

Let's pretend we have two entities: Entity A, and Entity B.

In a unidirectional relationship, Entity A would know about all its related Entity Bs.

However, you could not ask an Entity B which Entity A it was related too. The relationship information is one sided. It only goes one way - from A to B, but not from B to A. This is a unidirectional relationship.

Likewise, you could reverse the relationship so that an Entity B knows which Entity A's it is related too, but you could not ask an Entity A which Entity B it is related too. This, again, is a unidirectional relationship. It only goes one way - from B to A, but not from A to B.

An example of a unidirectional relationship in the real world may be software that is installed on a particular machine. The machine knows what software is installed, but the software doesn't know anything about the machine.

In code, this might look like:

<?php
/** @ORM\Entity */
class EntityA
{
    // ...

    /**
     * @ORM\ManyToOne(targetEntity="EntityB")
     */
    private $entityB;
}

/** @ORM\Entity */
class EntityB
{
    // no relationship mapping information back to EntityA
}

In a bidirectional relationship, you can ask either side (Entity A, or Entity B) about the other and it will know about / have a reference to that other entity.

In code this might look like:

<?php
use Doctrine\Common\Collections\ArrayCollection;

/** @ORM\Entity */
class EntityA
{
    /**
     * @ORM\OneToMany(targetEntity="EntityB", mappedBy="entityA")
     */
    private $collectionOfEntityBs;

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

/** @ORM\Entity */
class EntityB
{
    /**
     * @ORM\ManyToOne(targetEntity="EntityA", inversedBy="collectionOfEntityBs")
     * @ORM\JoinColumn(name="entity_b_id", referencedColumnName="id")
     */
    private $entityA;
}

Are Relationships Unidirectional Or Bidirectional

This is usually a tricky question to answer, as most of the time a good argument can initially be made for making every relationship a bidirectional relationship.

However, your application / code will be greatly simplified if you keep relationships as one sided as possible. Only make relationships bidirectional if absolutely necessary.

I like to think about GhostBusters - don't cross the streams! The more relationships you have flowing both ways, the more tangled and messy things get, and the harder your code becomes to reason about. You have 'streams' going everywhere, and suddenly your code looks like it has been slimed by Slimer.

Even worse, this is a problem that scales. If you have an entity that is related to a lot of other entities, you're going to end up looping through, paginating, filtering... It makes more sense just to run an explicit query, and get back exactly what you wanted to begin with.

It is generally fairly easy to write a DQL query to answer whatever questions you might have about the relationships between objects, so prefer querying to making all relationships bidirectional.

Defining A OneToMany / ManyToOne Relationship

In our example, one RedditAuthor may have many RedditPost entities.

Likewise, many RedditPost's may belong to one RedditAuthor.

After all that talk about unidirectional vs bidirectional relationships, and how we want to try for unidirectional as much as possible, I have immediately broken my own rules to make this demo a little more interesting.

A bidirectional OneToMany / ManyToOne relationship can be called by either name, which is to say a bidirectional OneToMany relationship == a bidirectional ManyToOne relationship.

The next step is to figure out which annotations go on which entity.

I have my own way of remembering this:

  • Singular = ManyToOne
  • Plural = OneToMany

I look at the last part to figure out which goes where. This becomes more obvious with a little code:

// src/AppBundle/Entity/RedditAuthor.php

class RedditAuthor
{
    // snip

    protected $posts;

A RedditAuthor may have many RedditPost entities, so $posts is plural.

// src/AppBundle/Entity/RedditPost.php

class RedditPost
{
    // snip

    protected $author;

A RedditPost may belong to one RedditAuthor, so $author is singular.

Using my system above, I can quickly figure out which annotation goes on either side of the relationship:

// src/AppBundle/Entity/RedditPost.php

use Doctrine\ORM\Mapping as ORM;

class RedditPost
{
    // snip

    /**
     * @ORM\ManyToOne()
     */
    protected $author;

ManyToOne ends in One. This goes on the singular side - the $author property.

// src/AppBundle/Entity/RedditPost.php

use Doctrine\ORM\Mapping as ORM;

class RedditPost
{
    // snip

    /**
     * @ORM\OneToMany()
     */
    protected $posts;

And likewise, OneToMany ends in Many. This goes on the plural side - the $posts property.

But we aren't done just yet. These annotations need a little further information to properly define the relationship.

Right at the start of this write up, we covered some core principles of Doctrine owning versus inverse sides in a relationship. The relevant parts in this instance:

  • A bidirectional relationship has both an owning side and an inverse side
  • The inverse side has to use the mappedBy attribute of the OneToOne, OneToMany, or ManyToMany mapping declaration
  • The owning side has to use the inversedBy attribute of the OneToOne, ManyToOne, or ManyToMany mapping declaration
  • ManyToOne is always the owning side of a bidirectional association
  • OneToMany is always the inverse side of a bidirectional association

From these rules we now know that RedditPost, having the ManyToOne annotation is therefore the owning side of this relationship.

We can therefore add in the appropriate attributes to the relationship annotation:

// src/AppBundle/Entity/RedditPost.php

use Doctrine\ORM\Mapping as ORM;

class RedditPost
{
    // snip

    /**
     * @ORM\ManyToOne(targetEntity="AppBundle\Entity\RedditAuthor", inversedBy="posts")
     * @ORM\JoinColumn(name="author_id", referencedColumnName="id")
     */
    protected $author;

Here we tell Doctrine that this $author property should be represented by an object of class AppBundle\Entity\RedditAuthor. This is the targetEntity attribute.

On that class, we expect there to be a property called $posts which will contain the relationship mapping from the inverse side. This is the inversedBy attribute.

We also explicitly define the JoinColumn information, but Doctrine could guess this correctly given that the defaults Doctrine would use would be the same. This join column information dictates the name of the column in our database table (name="author_id" gives us a column name called author_id), and also the property on the targetEntity to use as the reference. We use the RedditAuthor's ID, so are referencing the $id property on RedditAuthor.

The owning side is a little more verbose than the inverse side, so the next step is easier:

// src/AppBundle/Entity/RedditAuthor.php

use Doctrine\ORM\Mapping as ORM;

class RedditAuthor
{
    // snip

    /**
     * @ORM\OneToMany(targetEntity="AppBundle\Entity\RedditPost", mappedBy="author")
     */
    protected $posts;

Again, the rules tell us that a OneToMany is always the inverse side in a bidirectional association.

They also tell us that the inverse side will have a mappedBy attribute.

Here we are telling Doctrine that the $posts property will be represented by objects of class AppBundle\Entity\RedditPost. This is the targetEntity attribute.

On RedditPost, we expect there to be a property called $author which will contain the relationship mapping from the owning side. This is the mappedBy attribute.

A handy tip here is to use the Symfony plugin for PHPStorm to give code completion on annotations.

Handling Zero Or More Related Entities

The next hurdle comes in the form of how we handle 'many' things.

We have just told Doctrine that hey, any given RedditAuthor may have zero or more associated RedditPost's. And that zero is just as important as one, or many.

As soon as you add a OneToMany association, you should get into the habit of immediately initialising the collection. Doing so is super easy:

// src/AppBundle/Entity/RedditAuthor.php

use Doctrine\Common\Collections\ArrayCollection;

class RedditAuthor
{
    /**
     * @ORM\OneToMany(targetEntity="AppBundle\Entity\RedditPost", mappedBy="author")
     */
    protected $posts;

    /**
     * RedditAuthor constructor.
     */
    public function __construct()
    {
        $this->posts = new ArrayCollection();
    }

(Extra fields / properties / etc removed for brevity)

By using Doctrine's ArrayCollection we get additional benefits including allowing Doctrine's Lazy Loading. I love Doctrine's Collections, and have made a beginners guide to ArrayCollection tutorial which I recommend viewing.

This being a structural change to the database, we must remember to update Doctrine with the new mapping information:

php bin/console doctrine:schema:update --force

Even with all this, Doctrine still won't automatically start relating entities on our behalf.

Relating Entities

Switching back to our Reddit Scraper code, we must now explicitly start relating our entities. This is easy enough to do:

// src/AppBundle/Service/RedditScraper.php

foreach ($content['data']['children'] as $child) {
    $redditPost = new RedditPost();
    $redditPost->setTitle($child['data']['title']);

    $authorName = $child['data']['author'];
    $redditAuthor = $em->getRepository('AppBundle:RedditAuthor')->findOneBy(['name'=>$authorName]);

    if (!$redditAuthor) {
        $redditAuthor = new RedditAuthor();
        $redditAuthor->setName($authorName);
        $em->persist($redditAuthor);
        $em->flush();
    }

    $redditPost->setAuthor($redditAuthor); // the new line

    $em->persist($redditPost);
}

And upon re-running our scraper, lo-and-behold, our entities are now properly related.

But, to me, this seems a little back to front.

Even though the Doctrine relationship is configured to have the RedditPost owning the RedditAuthor, my mind tells me that RedditAuthor is the owner of the RedditPost, within the business domain / code / logic of my application.

I found this to be such a mind bender. What you have to realise is that this whole Owning and Inverse side is a concern only of Doctrine. It has absolutely no bearing on your code. Again, quoting directly from the Doctrine documentation:

“Owning side” and “inverse side” are technical concepts of the ORM technology, not concepts of your domain model. What you consider as the owning side in your domain model can be different from what the owning side is for Doctrine. These are unrelated.

The upshot being, just because our relationship is configured to satisfy Doctrine doesn't mean we are forced to implement that same owner / inverse behaviour in our own code.

Let's fix this immediately to behave the way that makes more sense to a human who knows what a Post and Author actually represent.

Letting Author Add Posts

Fixing this problem is as easy as adding in a new method to RedditAuthor.

The method will be simply named addPost and take a RedditPost object as its only argument:

// src/AppBundle/Entity/RedditAuthor.php

    public function addPost(RedditPost $redditPost)
    {
        if ( ! $this->posts->contains($redditPost)) {
            $this->posts->add($redditPost);
        }

        return $this;
    }

We could then update the RedditScraper code as follows:

// src/AppBundle/Service/RedditScraper.php

foreach ($content['data']['children'] as $child) {
    // snip 

    $redditAuthor->addPost($redditPost); // the changed line

    $em->persist($redditPost);
}

What we are saying here is that if the current RedditAuthor does not yet have this RedditPost in its collection of $posts, then add it.

Otherwise, we already do have the post in the collection then don't add it again.

You can learn more about the add method, and other similar methods, in this tutorial video.

There Is Still A Problem

This change hasn't fixed our problem just yet.

If we re-run the scraper now, we will find that the relationship information has been lost.

In our original scraper implementation we had the line:

$redditPost->setAuthor($redditAuthor);

And we replaced this line with our new method:

$redditAuthor->addPost($redditPost);

However, we still need to set the author (setAuthor). This is the way we are relating our entities.

And this is where it gets a little confusing. We must add this step back in, but inside the addPost method:

// src/AppBundle/Entity/RedditAuthor.php

    public function addPost(RedditPost $redditPost)
    {
        if ( ! $this->posts->contains($redditPost)) {
            $redditPost->setAuthor($this); // added the set author method back in
            $this->posts->add($redditPost);
        }

        return $this;
    }

We've added the method call back in, but changed it slightly.

Now when the setAuthor method is being called, we are in the context of a RedditAuthor. This is to say that the addPost method being called belongs to a specific RedditAuthor object.

However, we still need to tell our RedditPost which RedditAuthor object it should be related too.

Put another way, the passed in RedditPost should be related to this RedditAuthor. We use $this to refer to the current class.

Therefore $this represents the current RedditAuthor that has had the addPost method called on it.

If you can wrap your head around this then you will be in good standing for working with some of the more complex form examples.

Code For This Course

Get the code for this course.

Episodes