Custom Filter Logic for Entities

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

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

Login Subscribe

Let's review our goal: to be able to add ?search=cheese and have that search across several columns in the database.

In getDescription(), let's describe that lofty goal. We don't need any fancy, dynamic property stuff like we saw in the core filters. Just return an array with one item for the one query parameter: search. Set that to another array and... here is where we start using the keys that we saw inside PropertyFilter:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
... lines 11 - 14
public function getDescription(string $resourceClass): array
{
return [
'search' => [
... lines 19 - 21
]
];
}
}

I'll cheat and list the ones we need. First, set 'property' => null:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
... lines 11 - 14
public function getDescription(string $resourceClass): array
{
return [
'search' => [
'property' => null,
... lines 20 - 21
]
];
}
}

If a filter does relate to a specific property, put that here. As far as I can tell, this is only used in the Hydra filter documentation, not even in OpenAPI, which is what drives the Swagger interactive docs. Wow, that sentence was FULL of buzzwords. Phew!

Next set 'type' => 'string' and 'required' => false:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
... lines 11 - 14
public function getDescription(string $resourceClass): array
{
return [
'search' => [
'property' => null,
'type' => 'string',
'required' => false,
]
];
}
}

Both things that will help the docs.

Let's check it out! Find the API docs, refresh and open up the /api/cheeses operation. And... there it is: search! And it says "string".

We can even help this a bit more. Add another key called openapi. Set that to another array. One of the keys you can pass here is called description. How about:

search across multiple fields.

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
... lines 11 - 14
public function getDescription(string $resourceClass): array
{
return [
'search' => [
... lines 19 - 21
'openapi' => [
'description' => 'Search across multiple fields',
],
]
];
}
}

I personally do not know all the possible keys that we can have... I've just been digging around and finding what I need. So feel free to dig further.

When we refresh now and go back to that operation... nice! Our filter now has a description!

How & When filterProperty() is Called

Okay, enough with the documentation: let's get to the part where we actually apply that filter. For a Doctrine ORM filter, this happens in filterProperty(), which receives $property and $value arguments and then some Doctrine-specific stuff like QueryBuilder, something called a QueryNameGenerator - more on that in a few minutes - and a some other stuff:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
}
... lines 14 - 27
}

Let's figure out what that $property and $value are: dd() both of these:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
dd($property, $value);
}
... lines 15 - 28
}

Then go find the other tab where we're fetching the cheese collection and hit enter to send the ?search=cheese query param.

Cool! It passes us search - which is the name of the query parameter - and also cheese. Now, the really important thing to understand is that filterProperty() is going to be called for every single query parameter that's on the URL. So if I go up here and say ?dog=bark, it prints out dog and bark. Now, obviously our filter is not meant to operate on a dog, we'll leave that to trained veterinarians.

Back in filterProperty(), check for our query parameter: if $property does not equal 'search', then return:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
if ($property !== 'search') {
return;
}
... lines 16 - 19
}
... lines 21 - 34
}

So, it's a little weird because the method uses the word "property"... but search isn't really a property... it's just what we decided to call our query param. And that's fine. Also, there is no logic that connects what we return from getDescription() and filterProperty(). So if you were expecting that maybe filterProperty() would only be called for query parameters that we return in getDescription()... that's not how it works. These two methods work independently.

Modifying the Query

The rest of this method is good-old query-building logic. We're passed the QueryBuilder that will be used to fetch the collection, and our job is to modify it.

To do that, we first need to know the class alias that's being used for the query. We can get that by saying $alias = $queryBuilder->getRootAliases() and - it looks a bit funny - but we want the 0 index:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
if ($property !== 'search') {
return;
}
$alias = $queryBuilder->getRootAliases()[0];
... lines 18 - 19
}
... lines 21 - 34
}

Now add the real logic: $queryBuilder->andWhere(), pass this sprintf() - because the alias will be dynamic - and search on both the title and description fields. So, %s.title LIKE :search OR %s.description LIKE :search. For the 2 %s, pass $alias and $alias. And... I'll split this onto multiple lines:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
... lines 13 - 16
$alias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.title LIKE :search OR %s.description LIKE :search', $alias, $alias))
... line 19
}
... lines 21 - 34
}

Finish with ->setParameter() to assign the search parameter to '%'.$value.'%' for a fuzzy search:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
... lines 13 - 16
$alias = $queryBuilder->getRootAliases()[0];
$queryBuilder->andWhere(sprintf('%s.title LIKE :search OR %s.description LIKE :search', $alias, $alias))
->setParameter('search', '%'.$value.'%');
}
... lines 21 - 34
}

Let's try this! First, remove the query parameter entirely so we can see what the full list looks like. Ok, a lot of people are selling blocks of cheddar. Add ?search=cheddar and ah! No results!?

This smells like a typo! Bah! I added an extra s on the parameter:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
... lines 13 - 17
$queryBuilder->andWhere(sprintf('%s.title LIKE :search OR %s.description LIKE :search', $alias, $alias))
->setParameter('search', '%'.$value.'%');
}
... lines 21 - 34
}

Try it again. Much better! /api/cheeses/1 is gone but the rest do have cheddar in their title.

Let's try the word "cube" to see if the description is matching. And... that works too!

To prove it, we can even see the query. Go to /_profiler... click the token for our API request, click into the Doctrine section then "view formatted query". Beautiful! The is_published and owner_id check comes from a Doctrine extension we created in the last tutorial and relates to security. And then it searches on the title or description fields. Pretty cool.

The QueryNameGenerator

Before we keep going, one argument we did not use was $queryNameGenerator.

The query name generator is probably not very important unless you're creating a filter that you want to share between projects.

Here's the problem it solves: the parameter we added - search - could have been called anything - it just needs to match the :search inside the query:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
... lines 13 - 17
$queryBuilder->andWhere(sprintf('%s.title LIKE :search OR %s.description LIKE :search', $alias, $alias))
->setParameter('search', '%'.$value.'%');
}
... lines 21 - 34
}

Now, if there are many independent filters being used, then, in theory, two filters might accidentally use the same parameter name. If that happened one would override the other. That's no fun.

The query name generator's job is to help avoid this problem by generating a unique parameter name.

Check it out: say $valueParameter = $queryNameGenerator-> - that's the argument we're being passed - then generateParameterName('search'):

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
... lines 13 - 18
$valueParameter = $queryNameGenerator->generateParameterName('search');
... lines 20 - 21
}
... lines 23 - 36
}

That will return a string with search then a unique index that increments: something like search_p1 or search_p2. I'll put a comment above this:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
... lines 13 - 17
// a param name that is guaranteed unique in this query
$valueParameter = $queryNameGenerator->generateParameterName('search');
... lines 20 - 21
}
... lines 23 - 36
}

Down in the query, using it does get ugly: instead of :search, it's :%s and then another :%s. For the arguments, we need $alias, $valueParameter, $alias, $valueParameter:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
... lines 13 - 17
// a param name that is guaranteed unique in this query
$valueParameter = $queryNameGenerator->generateParameterName('search');
$queryBuilder->andWhere(sprintf('%s.title LIKE :%s OR %s.description LIKE :%s', $alias, $valueParameter, $alias, $valueParameter))
... line 21
}
... lines 23 - 36
}

Finally, in setParameter(), use $valueParameter:

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
... lines 13 - 17
// a param name that is guaranteed unique in this query
$valueParameter = $queryNameGenerator->generateParameterName('search');
$queryBuilder->andWhere(sprintf('%s.title LIKE :%s OR %s.description LIKE :%s', $alias, $valueParameter, $alias, $valueParameter))
->setParameter($valueParameter, '%'.$value.'%');
}
... lines 23 - 36
}

I'll be honest, that makes my head spin a little... and I might avoid doing this for custom filters in my own project.

Anyways, let's make sure it works by going to the documentation. Hit "Try it Out", fill in "cube" for the search and... Execute! Let's see... yep! Our filter is working!

So this is what it looks like to make a custom filter when your resource is a Doctrine entity. But the process is different if you need to make a custom filter for an API resource that is not an entity. Let's tackle that next by making a filter for DailyStats.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.2.5",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.7
        "doctrine/annotations": "^1.0", // 1.10.4
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.1
        "doctrine/orm": "^2.4.5", // v2.7.3
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.4
        "ramsey/uuid-doctrine": "^1.6", // 1.6.0
        "symfony/asset": "5.1.*", // v5.1.5
        "symfony/console": "5.1.*", // v5.1.5
        "symfony/debug-bundle": "5.1.*", // v5.1.5
        "symfony/dotenv": "5.1.*", // v5.1.5
        "symfony/expression-language": "5.1.*", // v5.1.5
        "symfony/flex": "^1.1", // v1.9.6
        "symfony/framework-bundle": "5.1.*", // v5.1.5
        "symfony/http-client": "5.1.*", // v5.1.5
        "symfony/monolog-bundle": "^3.4", // v3.5.0
        "symfony/security-bundle": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/validator": "5.1.*", // v5.1.5
        "symfony/webpack-encore-bundle": "^1.6", // v1.7.3
        "symfony/yaml": "5.1.*" // v5.1.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.3.2
        "symfony/browser-kit": "5.1.*", // v5.1.5
        "symfony/css-selector": "5.1.*", // v5.1.5
        "symfony/maker-bundle": "^1.11", // v1.21.1
        "symfony/phpunit-bridge": "5.1.*", // v5.1.5
        "symfony/stopwatch": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/web-profiler-bundle": "5.1.*", // v5.1.5
        "zenstruck/foundry": "^1.1" // v1.1.2
    }
}