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 SubscribeClick back to the homepage with no search query. We still have seven queries because we're still using our very simple findAllOrdered()
method... which doesn't have the JOIN
. So... we should add the JOIN
here too, right? Yep! Well... probably. But I want to show you an alternative solution.
Our homepage is unique because we don't really need all the FortuneCookie
data for each Category
... the only thing we need is the COUNT
.
Check out the template: we're not looping over category.fortuneCookies
and rendering the actual FortuneCookie
data. Nope, we're simply counting them. If you think about it, having a giant query that grabs all of the FortuneCookie
data.... just to count them... isn't the greatest thing for efficiency.
... lines 1 - 7 | |
{% for category in categories %} | |
<a class="bg-orange-400 hover:bg-orange-500 text-white text-center rounded-full p-4" href="{{ path('app_category_show', {'id': category.id}) }}"> | |
<span class="fa {{ category.iconKey }}"></span> <span class="font-bold text-lg">{{ category.name }}</span> ({{ category.fortuneCookies|length }}) | |
</a> | |
... lines 12 - 13 | |
{% endfor %} | |
... lines 15 - 17 |
If you find yourself in this situation, you can tell Doctrine to be clever with how it loads the relation. Go into the Category
entity and find the OneToMany
relationship for $fortuneCookies
. At the end, add fetch:
set to EXTRA_LAZY
.
... lines 1 - 10 | |
class Category | |
{ | |
... lines 13 - 23 | |
#[ORM\OneToMany(mappedBy: 'category', targetEntity: FortuneCookie::class, fetch: 'EXTRA_LAZY')] | |
private Collection $fortuneCookies; | |
... lines 26 - 89 | |
} |
Let's go see what that does. When you refresh, watch the query count. It stays at seven! But if we open up the profiler, the queries themselves have changed. The first one is the same: it queries from category
. But check out the others! We have SELECT COUNT(*) FROM fortune_cookie
over and over! So we do have seven queries, but now each is only selecting the COUNT
!
When you have fetch: 'EXTRA_LAZY'
and you simply count a collection relation, Doctrine is smart enough to select just the COUNT
instead of querying for all the data. If we were to loop over this collection and start printing out FortuneCookie
data, then it would still make a full query for the data. But if all we need is to count them, then fetch: 'EXTRA_LAZY'
is a great solution.
Ok: click into one of the categories. The profiler says that we have two queries. This is a, sort of, "miniature" N+1 problem. The first query selects a single Category
... and the second selects all the fortune cookies for this one category. Let's flex our JOIN
skills to get this down to one query.
Open up FortuneController
and find the showCategory()
action. By type-hinting Category
on this argument, we're telling Symfony to query for the Category
for us, by using the {id}
. Normally, I love this! However, in this case, because we want to add a JOIN
from Category
to fortuneCookies
, we need to take control of that query.
... lines 1 - 11 | |
class FortuneController extends AbstractController | |
{ | |
... lines 14 - 29 | |
public function showCategory(Category $category): Response | |
{ | |
return $this->render('fortune/showCategory.html.twig',[ | |
'category' => $category | |
]); | |
} | |
} |
Change this so that Symfony passes us the int $id
directly. Then, autowire CategoryRepository $categoryRepository
.
... lines 1 - 11 | |
class FortuneController extends AbstractController | |
{ | |
... lines 14 - 29 | |
public function showCategory(int $id, CategoryRepository $categoryRepository): Response | |
{ | |
... lines 32 - 36 | |
} | |
} |
Below, do the query manually with $category = $categoryRepository->
... calling a new method: findWithFortunesJoin($id)
. Before we create that, we also need to add if (!$category)
, then throw $this->createNotFoundException()
. You can give that a message if you want.
Ok, copy the method name, hop over to CategoryRepository
and say public function findWithFortunesJoin(int $id)
, which will return a Category
if one is found, else null
. I'll fix that typo in a minute.
... lines 1 - 29 | |
public function showCategory(int $id, CategoryRepository $categoryRepository): Response | |
{ | |
$category = $categoryRepository->findWithFortunesJoin($id); | |
if (!$category) { | |
throw $this->createNotFoundException('Category not found!'); | |
} | |
... lines 36 - 39 | |
} | |
... lines 41 - 42 |
The query starts like the other.... and we could steal some code... but since we're practicing, let's write it by hand. return $this->createQueryBuilder()
and pass our normal category
alias. Then ->andWhere('category.id = :id')
- I'll fix that typo in a minutes as well - filling in the wildcard with ->setParameter()
id
, $id
... ideally spelled correctly. Then ->getQuery()
.
... lines 1 - 17 | |
class CategoryRepository extends ServiceEntityRepository | |
{ | |
... lines 20 - 52 | |
public function findWithFortunesJoin(int $id): ?Category | |
{ | |
return $this->createQueryBuilder('category') | |
... lines 56 - 57 | |
->andWhere('category.id = :id') | |
->setParameter('id', $id) | |
->getQuery() | |
... line 61 | |
} | |
... lines 63 - 105 | |
} |
Until now, we've been fetching multiple rows... and so we've used ->getResult()
. But this time, we want either the one result or null if it can't be found. To do that, use ->getOneOrNullResult()
.
... lines 1 - 52 | |
public function findWithFortunesJoin(int $id): ?Category | |
{ | |
return $this->createQueryBuilder('category') | |
... lines 56 - 60 | |
->getOneOrNullResult(); | |
} | |
... lines 63 - 107 |
And that's it! That should get things working. I'll do a little sanity check over here, and... oh... it would probably help if I typed things correctly. But this is cool! It recognized that it didn't know what that alias was and gave us a clear error. And now... it works, and we still have two queries.
Time for the JOIN
! We're going from one Category
to many fortune cookies, so let's say ->leftJoin()
on category.
and the property name, which is fortuneCookies
. Once again, the order doesn't matter, but above I'll say ->addSelect('fortuneCookie')
. Oh, and I also need to add fortuneCookie
as a second argument inside the ->leftJoin()
: that's the alias.
... lines 1 - 52 | |
public function findWithFortunesJoin(int $id): ?Category | |
{ | |
return $this->createQueryBuilder('category') | |
->addSelect('fortuneCookie') | |
->leftJoin('category.fortuneCookies', 'fortuneCookie') | |
... lines 58 - 61 | |
} | |
... lines 63 - 107 |
So we're aliasing that joined entity to fortuneCookie
then selecting fortuneCookie
. Now, we should see this query number go from two to one. And... it did!
Here's the takeaway: while there's no need to over-optimize, if you have the N+1 problem, you can solve it by JOINing to the related table and selecting its data.
Ok, until now, Doctrine has returned a collection of Category
objects or a single Category
object. That's cool, but what if, instead of entire objects, we just need some data - like a few columns, a COUNT
, or a SUM
? Let's dig into that next.
"Houston: no signs of life"
Start the conversation!
// composer.json
{
"require": {
"php": ">=8.1",
"ext-ctype": "*",
"ext-iconv": "*",
"beberlei/doctrineextensions": "^1.3", // v1.3.0
"doctrine/doctrine-bundle": "^2.7", // 2.9.1
"doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
"doctrine/orm": "^2.13", // 2.15.1
"symfony/asset": "6.2.*", // v6.2.7
"symfony/console": "6.2.*", // v6.2.10
"symfony/dotenv": "6.2.*", // v6.2.8
"symfony/flex": "^2", // v2.2.5
"symfony/framework-bundle": "6.2.*", // v6.2.10
"symfony/proxy-manager-bridge": "6.2.*", // v6.2.7
"symfony/runtime": "6.2.*", // v6.2.8
"symfony/twig-bundle": "6.2.*", // v6.2.7
"symfony/webpack-encore-bundle": "^1.16", // v1.16.1
"symfony/yaml": "6.2.*" // v6.2.10
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.4
"symfony/maker-bundle": "^1.47", // v1.48.0
"symfony/stopwatch": "6.2.*", // v6.2.7
"symfony/web-profiler-bundle": "6.2.*", // v6.2.10
"zenstruck/foundry": "^1.22" // v1.32.0
}
}