ManyToMany Joins & When to Avoid ManyToMany

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $10.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

We have the N+1 query problem once again. Click to view those queries. The new queries are mixed in here, but you'll see 7 new queries that select from tag with an INNER JOIN so that it can find all the tags for just one Article. Each time we reference the tags for a new Article, it makes a new query for that article's tags.

This is quite possibly not something you need to worry about, at least, not until you can see a real performance issue on production. But, we should be able to fix it. The first query on this page finds all of the published articles. Could we add a join to that query to select the tag data all at once?

Totally! Open ArticleController and find the homepage() action. Right now, we're using $articles = $repository->findAllPublishedOrderedByNewest():

... lines 1 - 13
class ArticleController extends AbstractController
{
... lines 16 - 28
public function homepage(ArticleRepository $repository)
{
$articles = $repository->findAllPublishedOrderedByNewest();
... lines 32 - 35
}
... lines 37 - 63
}

Open ArticleRepository to check that out:

... lines 1 - 15
class ArticleRepository extends ServiceEntityRepository
{
... lines 18 - 22
/**
* @return Article[]
*/
public function findAllPublishedOrderedByNewest()
{
return $this->addIsPublishedQueryBuilder()
->orderBy('a.publishedAt', 'DESC')
->getQuery()
->getResult()
;
}
... lines 34 - 56
}

This custom query finds the Article objects, but does not do any special joins. Let's add one. But.... wait. This is weird. If you think about the database, we're going to need to join twice. We first need a LEFT JOIN from article to article_tag. Then, we need a another JOIN from article_tag to tag so that we can select the tag's data.

This is where Doctrine's ManyToMany relationship really shines. Don't think at all about the join table. Instead, ->leftJoin() on a.tags and use t as the new alias:

... lines 1 - 15
class ArticleRepository extends ServiceEntityRepository
{
... lines 18 - 22
/**
* @return Article[]
*/
public function findAllPublishedOrderedByNewest()
{
return $this->addIsPublishedQueryBuilder()
->leftJoin('a.tags', 't')
... lines 30 - 33
;
}
... lines 36 - 58
}

The a.tags refers to the tags property on Article. And because Doctrine knows that this is a ManyToMany relationship, it knows how to join all the way over to tag. To actually fetch the tag data, use ->addSelect('t'):

... lines 1 - 15
class ArticleRepository extends ServiceEntityRepository
{
... lines 18 - 22
/**
* @return Article[]
*/
public function findAllPublishedOrderedByNewest()
{
return $this->addIsPublishedQueryBuilder()
->leftJoin('a.tags', 't')
->addSelect('t')
... lines 31 - 33
;
}
... lines 36 - 58
}

That is it. Go back to our browser. The 15 queries are... back down to 8! Open the profiler to check them out. Awesome! The query selects everything from article and all the fields from tag. It can do that because it has both joins! That's nuts!

When a ManyToMany Relationship is Not What You Need

Ok guys, there is one last thing we need to talk about, and, it's a warning about ManyToMany relations.

What if we wanted to start saving the date of when an Article was given a Tag. Well, crap! We can't do that. We could record the date that a Tag was created or the date an Article was created, but we can't record the date when an Article was linked to a Tag. In fact, we can't save any extra data about this relationship.

Why? Because that data would need to live on this article_tag table. For example, we might want a third column called created_at. The problem is, when you use a ManyToMany relationship, you cannot add any more columns to the join table. It's just not possible.

This means that if, in the future, you do need to save extra data about the relationship, well, you're in trouble.

So, here's my advice: before you set up a ManyToMany relationship, you need to think hard and ask yourself a question:

Will I ever need to store additional metadata about this relationship?

If the answer is yes, if there's even one extra piece of data that you want to store, then you should not use a ManyToMany relationship. In fact, you can't use Doctrine at all, and you need to buy a new computer.

I'm kidding. If you need to store extra data on the article_tag table, then, instead, create a new ArticleTag entity for that table! That ArticleTag entity would have a ManyToOne relationship to Article and a ManyToOne relationship to Tag. This would effectively give you the exact same structure in the database. But now, thanks to the new ArticleTag entity, you're free to add whatever other fields you want.

If you generated a ManyToMany relationship by mistake and want to switch, it's not the end of the world. You can still create the new entity class and generate a migration so that you don't lose your existing data. But, if you can configure things in the beginning... well, even better.

Ok guys, you are now Doctrine pros! Your relationship skills strike fear at the heart of your enemies, and your ability to JOIN across tables is legendary among your co-workers.

Yes, there is more to learn, like how to write even more complex queries, and there are a lot of other, cool features - like Doctrine inheritance. But, all of the super important stuff that you need to create a real site? Yea, you got it down. So go out there, SELECT * FROM world, and build something amazing with Doctrine.

Alright guys, seeya next time.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-iconv": "*",
        "knplabs/knp-markdown-bundle": "^1.7", // 1.7.0
        "knplabs/knp-paginator-bundle": "^2.7", // v2.7.2
        "knplabs/knp-time-bundle": "^1.8", // 1.8.0
        "nexylan/slack-bundle": "^2.0,<2.2.0", // v2.0.0
        "php-http/guzzle6-adapter": "^1.1", // v1.1.1
        "sensio/framework-extra-bundle": "^5.1", // v5.1.4
        "stof/doctrine-extensions-bundle": "^1.3", // v1.3.0
        "symfony/asset": "^4.0", // v4.0.4
        "symfony/console": "^4.0", // v4.0.14
        "symfony/flex": "^1.0", // v1.2.7
        "symfony/framework-bundle": "^4.0", // v4.0.14
        "symfony/lts": "^4@dev", // dev-master
        "symfony/orm-pack": "^1.0", // v1.0.6
        "symfony/twig-bundle": "^4.0", // v4.0.4
        "symfony/web-server-bundle": "^4.0", // v4.0.4
        "symfony/yaml": "^4.0", // v4.0.14
        "twig/extensions": "^1.5" // v1.5.1
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.0", // 3.0.2
        "easycorp/easy-log-handler": "^1.0.2", // v1.0.4
        "fzaninotto/faker": "^1.7", // v1.7.1
        "symfony/debug-bundle": "^3.3|^4.0", // v4.0.4
        "symfony/dotenv": "^4.0", // v4.0.14
        "symfony/maker-bundle": "^1.0", // v1.4.0
        "symfony/monolog-bundle": "^3.0", // v3.1.2
        "symfony/phpunit-bridge": "^3.3|^4.0", // v4.0.4
        "symfony/profiler-pack": "^1.0", // v1.0.3
        "symfony/var-dumper": "^3.3|^4.0" // v4.0.4
    }
}