If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeEach published question now relates to 0 to 5 random tags. Over on the homepage, let's render the list of tags for each question under its vote count.
And I'm happy to report that using a ManyToMany relationship... isn't anything special. Open up the template for this page: templates/question/homepage.html.twig
. Down here... right after the vote string, add {% for tag in question.tags %}
.
... lines 1 - 9 | |
<div class="container"> | |
... lines 11 - 15 | |
<div class="row"> | |
{% for question in questions %} | |
<div class="col-12 mb-3"> | |
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);"> | |
<div class="q-container p-4"> | |
<div class="row"> | |
<div class="col-2 text-center"> | |
... lines 23 - 26 | |
{% for tag in question.tags %} | |
... line 28 | |
{% endfor %} | |
</div> | |
... lines 31 - 38 | |
</div> | |
</div> | |
... lines 41 - 45 | |
</div> | |
</div> | |
{% endfor %} | |
</div> | |
</div> | |
... lines 51 - 53 |
It's that easy: our Question
object has a tags
property that will return a collection of all the related Tag
objects. Behind the scenes, to get this data, Doctrine will need to query across the join table and the tag
table. But... we don't really care about that! We just get to say question.tags
and that returns all the Tag
objects for this Question
. It's really no different than how we could say question.answers
to get all of the answers for a question.
So inside the loop, we're dealing with a Tag
object. Add a span, print {{ tag.name }}
... and then I'll give this a couple of classes to make it look cool.
... lines 1 - 9 | |
<div class="container"> | |
... lines 11 - 15 | |
<div class="row"> | |
{% for question in questions %} | |
<div class="col-12 mb-3"> | |
<div style="box-shadow: 2px 3px 9px 4px rgba(0,0,0,0.04);"> | |
<div class="q-container p-4"> | |
<div class="row"> | |
<div class="col-2 text-center"> | |
... lines 23 - 26 | |
{% for tag in question.tags %} | |
<span class="badge rounded-pill bg-light text-dark">{{ tag.name }}</span> | |
{% endfor %} | |
</div> | |
... lines 31 - 38 | |
</div> | |
</div> | |
... lines 41 - 45 | |
</div> | |
</div> | |
{% endfor %} | |
</div> | |
</div> | |
... lines 51 - 53 |
Let's try this thing! Refresh and... done! We're awesome.
But check out the queries on this page: there are 41! Yikes! If you open this up, we have another N+1 problem. This first query is from the question
table: it returns all of the questions. This second query selects the tag
data for a specific question
... this is triggered when the question.tags
line is executed. Then... if you keep looking down - skip this one - we have that same query for the next question... and the same query for the next... and the next. We also have extra queries for counting the answers for each question, but ignore those right now.
So... when we render the tags for each question, we have the N+1 query problem! When we had this problem before on the answers page, we fixed it inside of AnswerRepository
... by joining across the question
relationship and then selecting the question
data. We can do the exact same thing again.
The controller for this page is src/Controller/QuestionController.php
... it's the homepage()
method.
... lines 1 - 15 | |
class QuestionController extends AbstractController | |
{ | |
... lines 18 - 30 | |
public function homepage(QuestionRepository $repository) | |
{ | |
$questions = $repository->findAllAskedOrderedByNewest(); | |
return $this->render('question/homepage.html.twig', [ | |
'questions' => $questions, | |
]); | |
} | |
... lines 39 - 80 | |
} |
To fetch the questions, we're already calling a custom repository method called findAllAskedOrderedByNewest()
.
Let's go find that: open up QuestionRepository
. Here it is. So far, it's pretty simple: it makes sure that the askedAt
is not null - that's this addIsAskedQueryBuilder()
part - and then orders the newest first.
... lines 1 - 15 | |
class QuestionRepository extends ServiceEntityRepository | |
{ | |
... lines 18 - 25 | |
public function findAllAskedOrderedByNewest() | |
{ | |
return $this->addIsAskedQueryBuilder() | |
->orderBy('q.askedAt', 'DESC') | |
->getQuery() | |
->getResult() | |
; | |
} | |
private function addIsAskedQueryBuilder(QueryBuilder $qb = null): QueryBuilder | |
{ | |
return $this->getOrCreateQueryBuilder($qb) | |
->andWhere('q.askedAt IS NOT NULL'); | |
} | |
... lines 40 - 56 | |
} |
To fix the N+1 problem, we need to add a join. And this is where things get interesting. In the database, we need to join from question
to question_tag
... and then join from question_tag
over to tag
. So we actually need two joins.
But in Doctrine, we get to pretend like that join table doesn't exist: Doctrine wants us to pretend that there is a direct relationship from question
to tag
. What I mean is, to do the join, all we need is ->leftJoin()
- because we want to get the many tags for this question - q.tags
, tag
.
... lines 1 - 15 | |
class QuestionRepository extends ServiceEntityRepository | |
{ | |
... lines 18 - 25 | |
public function findAllAskedOrderedByNewest() | |
{ | |
return $this->addIsAskedQueryBuilder() | |
->orderBy('q.askedAt', 'DESC') | |
->leftJoin('q.tags', 'tag') | |
... lines 31 - 33 | |
; | |
} | |
... lines 36 - 58 | |
} |
That's it. We reference the tags
property on question
... and let Doctrine figure out how to join over to that. The second argument - tag
- becomes the alias to the data on the tag
table. We need that to select its data: addSelect('tag')
.
... lines 1 - 15 | |
class QuestionRepository extends ServiceEntityRepository | |
{ | |
... lines 18 - 25 | |
public function findAllAskedOrderedByNewest() | |
{ | |
return $this->addIsAskedQueryBuilder() | |
->orderBy('q.askedAt', 'DESC') | |
->leftJoin('q.tags', 'tag') | |
->addSelect('tag') | |
... lines 32 - 33 | |
; | |
} | |
... lines 36 - 58 | |
} |
So... yup! Joining across a ManyToMany
relationship is no different than joining across a ManyToOne
relationship: you reference the relation property and Doctrine does the heavy lifting.
Try it now. We have 41 queries and... when we refresh... yes! Down to 21! Open up the profiler and look at that first query... it's pretty awesome. It selects all of the question
data... and then took care of left joining over to question_tag
, left joining again over to tag
and then selecting the tag data. So cool!
Next: the question_tag
table - the join table - only has 2 columns: question_id
and tag_id
. What if we wanted to add more columns to this? Like a taggedAt
date column? There's no entity class for this table... so is adding a 3rd or 4th column even possible? The answer is yes: but it does require some changes.
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"babdev/pagerfanta-bundle": "^3.3", // v3.3.0
"composer/package-versions-deprecated": "^1.11", // 1.11.99.3
"doctrine/doctrine-bundle": "^2.1", // 2.4.2
"doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
"doctrine/orm": "^2.7", // 2.9.5
"knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
"knplabs/knp-time-bundle": "^1.11", // v1.16.1
"pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
"pagerfanta/twig": "^3.3", // v3.3.0
"sensio/framework-extra-bundle": "^6.0", // v6.2.1
"stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
"symfony/asset": "5.3.*", // v5.3.4
"symfony/console": "5.3.*", // v5.3.7
"symfony/dotenv": "5.3.*", // v5.3.7
"symfony/flex": "^1.3.1", // v1.17.5
"symfony/framework-bundle": "5.3.*", // v5.3.7
"symfony/monolog-bundle": "^3.0", // v3.7.0
"symfony/runtime": "5.3.*", // v5.3.4
"symfony/stopwatch": "5.3.*", // v5.3.4
"symfony/twig-bundle": "5.3.*", // v5.3.4
"symfony/validator": "5.3.*", // v5.3.14
"symfony/webpack-encore-bundle": "^1.7", // v1.12.0
"symfony/yaml": "5.3.*", // v5.3.6
"twig/extra-bundle": "^2.12|^3.0", // v3.3.1
"twig/string-extra": "^3.3", // v3.3.1
"twig/twig": "^2.12|^3.0" // v3.3.2
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
"symfony/debug-bundle": "5.3.*", // v5.3.4
"symfony/maker-bundle": "^1.15", // v1.33.0
"symfony/var-dumper": "5.3.*", // v5.3.7
"symfony/web-profiler-bundle": "5.3.*", // v5.3.5
"zenstruck/foundry": "^1.1" // v1.13.1
}
}