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 SubscribeWhen we render the answers for a question, we only want to render the approved answers. I got clever and did this by adding a getApprovedAnswers()
method. It loops over all of the answers... but then returns only the approved ones.
... lines 1 - 14 | |
class Question | |
{ | |
... lines 17 - 156 | |
public function getApprovedAnswers(): Collection | |
{ | |
return $this->answers->filter(function(Answer $answer) { | |
return $answer->isApproved(); | |
}); | |
} | |
... lines 163 - 184 | |
} |
The problem with this approach is... performance. It's pretty silly to query for every Answer
related to this question... and then only render some of them.
Realistically, if there are only ever a few non-approved answers, this is no big deal. But if it's possible that a question could have many non-approved answers, this page could really slow down. Imagine querying for 200 answers because some SPAM bot hit our site... only to render 6 of them.
Unfortunately, because we're in an entity, we can't simply grab the AnswerRepository
service from inside of this method and create a custom query. So... are we stuck? Do we need to back up to our controller and do a custom query for the answers there?
Fortunately, no! These Doctrine Collection objects have a few tricks up their sleeves, including a special "criteria" system for just this situation. It allows us to describe how we want to filter the answers and then it uses that when it queries!
Remove the filter stuff entirely... and instead say $criteria = Criteria
- the one from Doctrine\Collections
- ::create()
.
... lines 1 - 7 | |
use Doctrine\Common\Collections\Criteria; | |
... lines 9 - 15 | |
class Question | |
{ | |
... lines 18 - 157 | |
public function getApprovedAnswers(): Collection | |
{ | |
$criteria = Criteria::create() | |
... lines 161 - 163 | |
} | |
... lines 165 - 186 | |
} |
This object "kind of" looks like a query builder. For example, it has an ->andWhere()
method. The big difference is what we put inside of this. Instead of a simple string, we need to use a criteria "expression": Criteria::expr()
, ->eq()
- for equals - and pass this status
: the property we want to use in the WHERE. For the second arg, use Answer::STATUS_APPROVED
.
... lines 1 - 7 | |
use Doctrine\Common\Collections\Criteria; | |
... lines 9 - 15 | |
class Question | |
{ | |
... lines 18 - 157 | |
public function getApprovedAnswers(): Collection | |
{ | |
$criteria = Criteria::create() | |
->andWhere(Criteria::expr()->eq('status', Answer::STATUS_APPROVED)); | |
... lines 162 - 163 | |
} | |
... lines 165 - 186 | |
} |
This Criteria
object now "describes" how we want to filter the answers: "where status = approved". To use this say $this->answers->matching($criteria)
.
... lines 1 - 7 | |
use Doctrine\Common\Collections\Criteria; | |
... lines 9 - 15 | |
class Question | |
{ | |
... lines 18 - 157 | |
public function getApprovedAnswers(): Collection | |
{ | |
$criteria = Criteria::create() | |
->andWhere(Criteria::expr()->eq('status', Answer::STATUS_APPROVED)); | |
return $this->answers->matching($criteria); | |
} | |
... lines 165 - 186 | |
} |
For me, the criteria syntax - especially the expression stuff - is a little cryptic. But it's still usually pretty easy to create whatever logic I need. And, most importantly, it gets the job done.
Check it out: we have 6 approved answers now. After we refresh... good: this still renders 6 answers. Open the Doctrine profiler to look at the queries. And... amazing! When we call getApprovedAnswers()
, it now queries from answer where question_id
equals this question and status = 'approved
! Even the COUNT()
query above this is smart!
So... that's the criteria system! And other than getting a little bit fancier with the expressions you create... it's just that simple and powerful. It's one of my favorite secrets inside Doctrine.
By the way, if you don't like having the query logic in your entity, I don't blame you! But no worries: we can move it into our repository. Copy the criteria code and then open up src/Repository/AnswerRepository.php
... since this criteria relates to answers. Delete the example code and replace it with a new public static function called createApprovedCriteria()
. This will, of course, return a Criteria
object. Paste the logic and return.
... lines 1 - 6 | |
use Doctrine\Common\Collections\Criteria; | |
... lines 8 - 15 | |
class AnswerRepository extends ServiceEntityRepository | |
{ | |
... lines 18 - 22 | |
public static function createApprovedCriteria(): Criteria | |
{ | |
return Criteria::create() | |
->andWhere(Criteria::expr()->eq('status', Answer::STATUS_APPROVED)); | |
} | |
} |
There are two reasons I'm making this static. First, because I want to be able to call this from my Question
entity. And since I can't inject service objects into an entity, the only way to do that is by making this method static. And second... this method can be static! It doesn't need to use the $this
variable to call any methods on the repository.
Anyways, now that we have this, back in Question
we can simplify to return $this->answers->matching()
, AnswerRepository::createApprovedCriteria()
.
... lines 1 - 4 | |
use App\Repository\AnswerRepository; | |
... lines 6 - 16 | |
class Question | |
{ | |
... lines 19 - 158 | |
public function getApprovedAnswers(): Collection | |
{ | |
return $this->answers->matching(AnswerRepository::createApprovedCriteria()); | |
} | |
... lines 163 - 184 | |
} |
Cool! If you go back to the site and try it now... still 6 questions: it still works.
One other cool thing about these Criteria
objects is that you can reuse them with the query builder. For example, suppose we need to create a custom query that returns 10 approved answers. Add a new method for this: public function findAllApproved()
with an int $max = 10
argument... and this will return and array. Though, more specifically, I'll advertise in PHPDoc that this will return an array of Answer
objects.
... lines 1 - 15 | |
class AnswerRepository extends ServiceEntityRepository | |
{ | |
... lines 18 - 28 | |
/** | |
* @return Answer[] | |
*/ | |
public function findAllApproved(int $max = 10): array | |
{ | |
... lines 34 - 38 | |
} | |
} |
Inside, create the query builder like normal: return $this->createQueryBuilder()
and pass it answer
for the alias. To filter for only approved answers, we would normally say ->andWhere('answer.status = :status')
. But... I want to do this without duplicating the approved logic that we already wrote in the criteria method. Fortunately, we can put a criteria into a QueryBuilder
by saying ->addCriteria()
and then self::createApprovedCriteria()
.
... lines 1 - 15 | |
class AnswerRepository extends ServiceEntityRepository | |
{ | |
... lines 18 - 28 | |
/** | |
* @return Answer[] | |
*/ | |
public function findAllApproved(int $max = 10): array | |
{ | |
return $this->createQueryBuilder('answer') | |
->addCriteria(self::createApprovedCriteria()) | |
... lines 36 - 38 | |
} | |
} |
Cool huh? From here, we can finish the query like normal: ->setMaxResults($max)
and then ->getQuery()->getResult()
.
... lines 1 - 15 | |
class AnswerRepository extends ServiceEntityRepository | |
{ | |
... lines 18 - 28 | |
/** | |
* @return Answer[] | |
*/ | |
public function findAllApproved(int $max = 10): array | |
{ | |
return $this->createQueryBuilder('answer') | |
->addCriteria(self::createApprovedCriteria()) | |
->setMaxResults($max) | |
->getQuery() | |
->getResult(); | |
} | |
} |
I won't use this method right now, but hopefully you get the idea.
Next: let's add a new page to our site that shows the most popular answers. This will give us a great example to learn more about - then solve - the N+1 problem.
Holly smoke. I'm so glad I decided to watch this "old" Symfony 5 serie. Either I forgot about this criteria thing or had never heard of it! But this is a game changer!
#awesomeness
Hello Ryan,
Thanks for showing us this feature, it's amazing to know that there is multiple ways to customize queries, but I am little bit confused : which one to choose to get Data from DB : Criteria Or QueryBuilder ? Is one option better than the other?
I noticed that it wasn't possible to use Repository in our Entity like we did in the controller, but I want to know if there are other things that will let us to choose one of the both options.
Thanks again
Hey Hanane,
Definitely the QuestionBuilder, it's more flexible and I'd recommend you to always start with it, and in some cases when it doesn't work for you - you may fallback to Criteria. So, when use one or another - it depends on specific case, but as you already guessed yourself - Criteria mostly used in entities to customize (filter) the collection, because as you said you can't use QueryBuilder in entities - it's not recommended as entities are simple data and injecting any services into it is a bad practice.
I hope this helps!
Cheers!
In what classes also we should not inject services?
Honestly, I was a little bit confused when Ryan said that we can't use repository in an entity.
And I was going to ask you why, because I thought we can autowire services in ANY class inside src folder.
But then I saw your comment where you say that it's just a bad practice.
Hey!
Well, it depends. on your application, but mostly you have controllers, entities and... services in your app. Actually, almost everything in your app is a service, and you can inject services into other services. Unfortunately, data objects like your entities are not services, and so it's not correct to inject other services into entities. The idea is that you operate data in your controllers/services. So, if you think about it - I bet you will realise that you don't need to inject anything in entities, because most probably you're using those data in controllers, where you can inject a repository class and execute any query you want to fetch the data you need. So, controllers is the place where you get some data and pass them to template where those data will be rendered. I.e. in your project you operate data using controllers and services, something like this.
I hope this helps!
Cheers!
I kind of understand you, but I have a question.
As Ryan always say WORK = SERVICE.
What if we need some work, when we try to get some entity field?
For example, in Question entity we have getVotesString() method, which contains logic for processing votes field (adds + or - sign).
But what if I need more complex logic to process that field and return in some other format. Could it require some WORK?
Hey Phy,
Great!
If you need to do some work - create a service for this. If we're talking about entities - they already have repositories, so you can pass some simple logic there. Otherwise, create a standalone service and use, i.e. pass an entity to that service that need to do some work with their data. The service will do the complex work, e.g. any heavy calculations you need, and then set the final value to the entity.
But if we're talking about some simple actions, like incrementing the value that does not need any third-party services - you can do this in the entity, i.e. create a special method for this, e.g.:
public function incrementVotes()
{
$this->votes++
}
So, it depends on the complexity of the business logic you need. I hope this clarify things for you!
Cheers!
// 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
}
}
wow.. its been a while since I've deep dived into the latest symfony stuff. I'm starting a new role soon so I'm going through all your amazing tutorials on Symfony 5 and doctrine etc to get back up to date.
criteria is an amazing feature.. symfony always blows my mind.
thank you Ryan and team :)