Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Criteria: Filter Relation Collections

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 $6.00

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

Login Subscribe

On the category show page, we're looping over all the fortune cookies in that category. Let's check out the template: templates/fortune/showCategory.html.twig. Here it is: we loop over category.fortuneCookies and render some stuff.

... lines 1 - 20
{% for fortuneCookie in category.fortuneCookies %}
<tr class="hover:bg-slate-200">
<td class="border p-4">
{{ fortuneCookie.fortune }}
</td>
<td class="border p-4">
{{ fortuneCookie.numberPrinted }} printed since {{ fortuneCookie.createdAt|date('M jS Y') }}
</td>
</tr>
{% endfor %}
... lines 31 - 40

But... there's a problem. Open up the FortuneCookie entity. It has a bool $discontinued flag. Occasionally, we have to stop producing a specific fortune cookie... for one reason or another. Like the time we had a fortune cookie that said "You will be happy... until you realize reality is an illusion". That one slipped past quality control. When this happens, we set discontinued to true.

At the moment, we're looping over all the fortune cookies for a category: including both current and discontinued cookies! But management is really only interested in current fortune cookies. We need a way to hide the discontinued ones. How can we do that?

Over in the controller for this page - FortuneController - we could create a separate query from the $fortuneCookieRepository with

WHERE category = :category and discontinued = false.

But... that's lame! Looping over category.fortuneCookies is so easy! Do we really need to back up to the controller, create a custom query and pass in the results as a new Twig variable? Couldn't we somehow use the category object... but filter out the discontinued cookies! Absolutely! And if we do it correctly, we can do it really efficiently.

The first step is optional, but in the controller, change ->findWithFortunesJoin() back to just ->find(). I'm doing this - which removes the join - just so that it's a easier to see the end result of what we're about to do.

... lines 1 - 12
class FortuneController extends AbstractController
{
... lines 15 - 30
public function showCategory(int $id, CategoryRepository $categoryRepository, FortuneCookieRepository $fortuneCookieRepository): Response
{
$category = $categoryRepository->find($id);
... lines 34 - 44
}
}

Doing this doesn't change the page... except that our queries go up to three. That's one query for the Category, our custom query that we're making, and then one query for all the fortunes inside of this Category.

Adding a Custom Entity Method for Discontinued Cookies

Remember the goal: we want to be able to call something on the Category object to get back the related fortune cookies... but hiding the discontinued ones.

Open up the Category entity and find getFortuneCookies(). There it is. Below, add a new method called getFortuneCookiesStillInProduction(). This, like the normal method, will return a Doctrine Collection. And... just to help my editor, copy the @return doc above to say that this is a Collection of FortuneCookie objects.

... lines 1 - 10
class Category
{
... lines 13 - 60
/**
* @return Collection<int, FortuneCookie>
*/
public function getFortuneCookiesStillInProduction(): Collection
{
}
... lines 68 - 97
}

So... what do we do inside? We could loop over $this->fortuneCookies as $fortuneCookie and create an array of objects that are not discontinued. Easy!

But... as soon as we start working with $this->getFortuneCookies(), that will cause Doctrine to query for every related fortune cookie. Do you see the problem? We might be asking Doctrine to query and prepare 100 FortuneCookie objects... even though this final $inProduction collection may only contain 10 of them. What a waste!

What we really want to do is tell Doctrine that when it makes the query for the related fortune cookies, it should add an extra WHERE discontinued = false to that query.

Hello Criteria

But... how the heck do we do that? Doctrine makes that query automatically and... magically somewhere in the background. Whelp, this is where the criteria system comes in handy.

It works like this: $criteria = Criteria:: - the one from Doctrine\Common\Collections - create().

... lines 1 - 7
use Doctrine\Common\Collections\Criteria;
... lines 9 - 11
class Category
{
... lines 14 - 64
public function getFortuneCookiesStillInProduction(): Collection
{
$criteria = Criteria::create()
... lines 68 - 70
}
... lines 72 - 101
}

This object is a bit like the QueryBuilder, but not exactly the same. We can say ->andWhere() and then use Criteria:: again with expr()->. This expr() or "expression" lets us, sort of, build the WHERE clause. It has methods like in, contains or gt for "greater than". We want eq() for "equals". Inside, say discontinued, false.

... lines 1 - 64
public function getFortuneCookiesStillInProduction(): Collection
{
$criteria = Criteria::create()
->andWhere(Criteria::expr()->eq('discontinued', false));
... lines 69 - 70
}
... lines 72 - 103

Ok, this, by itself, just creates an object that "describes" a WHERE clause that could be added to some other query. To use it, return $this->fortuneCookies->matching($criteria).

... lines 1 - 64
public function getFortuneCookiesStillInProduction(): Collection
{
... lines 67 - 69
return $this->fortuneCookies->matching($criteria);
}
... lines 72 - 103

Cool, huh? We're saying:

Hey Doctrine! Take this collection, but only return the ones that match this criteria.

And as we'll see in a minute, this will modify the query to get those fortune cookies!

To use this method, over in showCategory.html.twig, replace the category.fortuneCookies loop with category.fortuneCookiesStillInProduction.

... lines 1 - 2
{% block body %}
... lines 4 - 20
{% for fortuneCookie in category.fortuneCookiesStillInProduction %}
... lines 22 - 29
{% endfor %}
... lines 31 - 38
{% endblock %}

Let's do this! Refresh, and... I don't actually know if any of these are discontinued, but it did go from three to two! And the best part? Check out that query! Here's the first one for the category, here's our custom one... but take a look at this last query. When we ask for the "fortune cookies still in production", it queries from fortune_cookie, where the category = our category and where t0.discontinued is false! So it made the most efficient query to fetch just the fortune cookies that we need. That's amazing.

Organizing your Criteria Code in the Repository

Now, one minor downside is that... I normally like to keep my query logic inside a repository... not in the middle of an entity. Fortunately, we can move it there.

Because this deals with fortune cookies, open FortuneCookieRepository and, anywhere, add a new public static function called... how about createFortuneCookiesStillInProductionCriteria(). This will return a Criteria object.

Now, grab the $criteria statement from the entity... and return that.

... lines 1 - 8
use Doctrine\Common\Collections\Criteria;
... lines 10 - 19
class FortuneCookieRepository extends ServiceEntityRepository
{
... lines 22 - 26
public static function createFortuneCookiesStillInProductionCriteria(): Criteria
{
return Criteria::create()
->andWhere(Criteria::expr()->eq('discontinued', false));
}
... lines 32 - 100
}

The Method is Static?

And yes, this is a static method... which I don't use too often. There are two reasons for this. First, these Criteria objects aren't actually making queries... and they don't rely on any data or services. And so, this method can be static. Second, and more importantly, we don't have access to the repository object from inside Category. So... if we want to call a method on a repository, it needs to be static. This is a special thing I typically do in my repositories only for this criteria situation.

Back in the entity, say $criteria equals FortuneCookieRepository::createFortuneCookiesStillInProductionCriteria().

... lines 1 - 5
use App\Repository\FortuneCookieRepository;
... lines 7 - 12
class Category
{
... lines 15 - 65
public function getFortuneCookiesStillInProduction(): Collection
{
$criteria = FortuneCookieRepository::createFortuneCookiesStillInProductionCriteria();
return $this->fortuneCookies->matching($criteria);
}
... lines 72 - 101
}

Logic centralization, check! Oh, and we can even reuse these Criteria objects inside a QueryBuilder. Let's see... I don't have a good example... so... in this method, above, let's pretend I'm creating a QueryBuilder with $this->createQueryBuilder('fortune_cookie'). To add the criteria it's... ->addCriteria(self::createFortuneCookiesStillInProduction()).

So, even though the criteria system is a bit different from the normal QueryBuilder, we can still reuse them everywhere. Oh, and let's check that things are still working. We're good!

Using the Criteria System in the Controller + EXTRA_LAZY Fetch

On the homepage, we have a similar problem. This says "Proverbs(3)", but if we click that, there are two. What's happening here? Over in homepage.html.twig... let's see... ah, yes. We're looping over categories, and then calling category.fortuneCookies|length which, as we know, returns all the fortune cookies. Change that to fortuneCookiesStillInProduction.

... lines 1 - 2
{% block body %}
... lines 4 - 7
{% for category in categories %}
... line 9
<span class="fa {{ category.iconKey }}"></span> <span class="font-bold text-lg">{{ category.name }}</span> ({{ category.fortuneCookiesStillInProduction|length }})
... lines 11 - 13
{% endfor %}
... line 15
{% endblock %}

Back on the homepage, watch this "(3)". It should go down to 2, and... it does. But that's not even the best part. Open up the query for that. Remember, thanks to our fetch EXTRA_LAZY, because we're only counting the number of fortune cookies, it knows to make a super-fast COUNT query. And thanks to the criteria system, it's selecting COUNT FROM fortune_cookies WHERE the category = our category and discontinued = false. Wow!

Next: We want to hide discontinued fortune cookies from everywhere on our site. Is there a way that we could hook into Doctrine and add that WHERE clause automatically... everywhere? There is. It's called filters.

Leave a comment!

0
Login or Register to join the conversation
Cat in space

"Houston: no signs of life"
Start the conversation!

What PHP libraries does this tutorial use?

// 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
    }
}
userVoice