Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Filters: Automatically Modify Queries

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

Thanks to our cool new method, we can filter out discontinued fortune cookies. But what if we want to apply some criteria like this globally to every query to a table? Like, telling Doctrine that whenever we query for fortune cookies, we want to add a WHERE discontinued = false to that query.

That sounds crazy. And yet, it's totally possible. To demonstrate, let's revert our two templates back to the way they were before. And now... if we go into "Proverbs"... yep! All 3 fortunes show up again.

Hello Filters

To apply a "global" WHERE clause, we can create a Doctrine filter. In the src/ directory, add a new directory called Doctrine/ for organization. Inside that, add a new class called DiscontinuedFilter. Make this extend SQLFilter... then go to Code -> Generate (or "command" + "N" on a Mac) and select "Implement Methods" to generate the one method we need addFilterConstraint().

... lines 1 - 4
use Doctrine\ORM\Mapping\ClassMetadata;
use Doctrine\ORM\Query\Filter\SQLFilter;
class DiscontinuedFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias)
{
// TODO: Implement addFilterConstraint() method.
}
}

Once we have things set up, Doctrine will call addFilterConstraint() when it's building any query and pass us some info about which entity we're querying for: that's this ClassMetadata thing. It will also pass us the $targetTableAlias, which we'll need in a minute to modify the query.

Oh, and to avoid a deprecation notice, add a string return type to the method.

To better see what's happening, let's do our favorite thing and dd($targetEntity, $targetTableAlias).

... lines 1 - 9
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
{
dd($targetEntity, $targetTableAlias);
}
... lines 14 - 15

Activating the Filter

But... when we head over and refresh the page... nothing happens! Unlike some things, filters are not activated automatically simply by creating the class. Activating it is a two-step process.

First, in config/packages/doctrine.yaml, we need to tell Doctrine that the filter exists. Anywhere directly under the orm key, add filters and then fortuneCookie_discontinued. That string could be anything... and you'll see how we use it in a minute. Set this to the class: App\Doctrine\DiscontinuedFilter.

doctrine:
... lines 2 - 7
orm:
... lines 9 - 18
filters:
fortuneCookie_discontinued: App\Doctrine\DiscontinuedFilter
... lines 21 - 47

Easy peasy.

This is now registered with Doctrine... but as you can see over here, it's still not called. The second step is to activate it where you want it. In some cases, you might want this DiscontinuedFilter to be used on one section of your site, but not on another.

Open the controller... there we go... head up to the homepage and autowire EntityManagerInterface $entityManager. Then, right on top, say $entityManager->getFilters() followed by ->enable(). Then pass this the same key we used in doctrine.yaml - fortuneCookie_discontinued. Go grab it... and paste.

... lines 1 - 13
class FortuneController extends AbstractController
{
... line 16
public function index(Request $request, CategoryRepository $categoryRepository, EntityManagerInterface $entityManager): Response
{
$entityManager->getFilters()
->enable('fortuneCookie_discontinued');
... lines 21 - 30
}
... lines 32 - 48
}

With any luck, every query that we make after this line will use that filter. Head over to the homepage and... yes! It hit it!

And woh! This ClassMetadata is a big object that knows all about our entity. Down here, apparently, for whatever query we're making first, the table alias - the alias being used in the query - is c0_. Ok! Let's get to work!

Adding the Filter Logoc

As I mentioned, this will be called for every query. So we need to be careful to only add our WHERE clause when we're querying for fortune cookies. To do that, say if $targetEntity->name !== FortuneCookie::class, then return ''.

... lines 1 - 8
class DiscontinuedFilter extends SQLFilter
{
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
{
if ($targetEntity->getReflectionClass()->name !== FortuneCookie::class) {
return '';
}
... lines 16 - 17
}
}

This method returns a string... and that string is basically added to a WHERE clause. At the bottom, return sprintf('%s.discontinued = false'), passing $targetTableAlias for the wildcard.

... lines 1 - 10
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
{
... lines 13 - 16
return sprintf('%s.discontinued = false', $targetTableAlias);
}
... lines 19 - 20

Ready to check this out? On the homepage, the "Proverbs" count should go from 3 to 2. And... it does! Check out the query for this. Yup! It has t0.discontinued = false inside of every query for fortune cookies. That's awesome!

Passing Parameters to Filters

Now, one tricky thing about these filters is that they are not services. So you can't have a constructor... it's just not allowed. If we need to pass something to this - like some config - we have to do it a different way. For example, let's pretend that sometimes we want to hide discontinued cookies... but other times, we want to show only discontinued ones - the reverse. Essentially, we want to be able to toggle this value from false to true.

To do that, change this to %s and fill it in with $this->getParameter()... passing some string I'm making up: discontinued. You'll see how that's used in a minute.

... lines 1 - 10
public function addFilterConstraint(ClassMetadata $targetEntity, $targetTableAlias): string
{
... lines 13 - 16
return sprintf('%s.discontinued = %s', $targetTableAlias, $this->getParameter('discontinued'));
}
... lines 19 - 20

Now, I don't normally add %s to my queries... because that can allow SQL injection attacks. In this case, it's okay, but only because the getParameter() method is designed to escape the value for us. In every other situation, avoid this.

If we head over and try it now... we get a giant error! Yay!

Parameter 'discontinued' does not exist.

That's true! As soon as you read a parameter, you need to pass that in when you enable the filter. Do that with ->setParameter('discontinued')... and let's say false.

... lines 1 - 13
class FortuneController extends AbstractController
{
... line 16
public function index(Request $request, CategoryRepository $categoryRepository, EntityManagerInterface $entityManager): Response
{
$entityManager->getFilters()
->enable('fortuneCookie_discontinued')
->setParameter('discontinued', false);
... lines 22 - 31
}
... lines 33 - 49
}

If we reload now... it's working! What happens if we change this to true? Refresh again and... yep! The number changed! We rule!

Activating this Globally

Though... you're probably thinking:

Ryan, dude, yea, this is cool... but can't I enable this filter globally... without needing to put this code in every controller?

Absolutely! Head back to the controller and comment this out.

When we do that, the number goes back to 3. To enable it globally, head back to the configuration: we're going to make this a little more complicated. Bump this onto a new line, set that to class then set enabled to true.

And just like that, this will be enabled everywhere... though you could still disable it in specific controllers. Oh, but since we have the parameter, we also need parameters, with discontinued: false.

doctrine:
... lines 2 - 7
orm:
... lines 9 - 18
filters:
fortuneCookie_discontinued:
class: App\Doctrine\DiscontinuedFilter
enabled: true
parameters:
discontinued: false
... lines 25 - 51

And... there we go! Filters are cool.

Next: Let's talk about how to use the handy IN operator with a query.

Leave a comment!

5
Login or Register to join the conversation
Markchicobaby Avatar
Markchicobaby Avatar Markchicobaby | posted 1 month ago

Hi, really great explanation of filters, thanks. I got them working but I'm trying to filter by the current user, so that each table can be automatically filtered to show only records owned by that user. I can't work out how to send in a parameter value that changes (ie $this->getUser() ) and enable this globally. (I can get it working if I enable per-call but then I feel I may as well just add the WHERE clause to each query). I'm trying to make the application multi-tenant, but using global filters. What do you recommend?

Reply

Hey @Markchicobaby

IIRC these filters are the same services as any in the Symfony, so in theory, you can inject any service into the filter to get access to any parameter.

Cheers!

Reply
Markchicobaby Avatar
Markchicobaby Avatar Markchicobaby | sadikoff | posted 1 month ago

But about half way in it says "Now, one tricky thing about these filters is that they are not services" so I didn't think that was possible.

Reply

bah... my bad =) sorry for that. Yeah, that is a pretty tricky situation. However, there still are some ways to go.

For example you can use request event

namespace App\EventSubscriber;

use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Security;

class DoctrineFilterSubscriber implements EventSubscriberInterface
{
    private $em;
    private $security;

    public function __construct(EntityManagerInterface $em, Security $security)
    {
        $this->em = $em;
        $this->security = $security;
    }

    public function onKernelRequest(RequestEvent $event)
    {
        if(!$user = $this->security->getUser())
        {
            return;
        }

        $filter = $this->em->getFilters()->enable('yourFilter');
        $filter->setParameter('user', $user->getId());
    }

    public static function getSubscribedEvents()
    {
        return [
            KernelEvents::REQUEST => 'onKernelRequest',
        ];
    }
}

Sorry again for my inattention!

Cheers!

1 Reply
Markchicobaby Avatar
Markchicobaby Avatar Markchicobaby | sadikoff | posted 1 month ago

OK cool I’ll give events a closer look. Thanks for the tip!

Reply
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