Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Custom Filter, getDescription() & properties

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

On CheeseListing, thanks to SearchFilter and also BooleanFilter and RangeFilter, when we use the collection endpoint, there are a number of different ways that we can filter, like searching the title, description and owner fields:

... lines 1 - 18
/**
... lines 20 - 45
* @ApiFilter(BooleanFilter::class, properties={"isPublished"})
* @ApiFilter(SearchFilter::class, properties={
* "title": "partial",
* "description": "partial",
* "owner": "exact",
* "owner.username": "partial"
* })
* @ApiFilter(RangeFilter::class, properties={"price"})
... lines 54 - 57
*/
class CheeseListing
{
... lines 61 - 217
}

That's pretty cool!

Except, we can only search by one field at a time. Like, we can search for something in the title or something in the description but we can't, for example, say ?search= and have that automatically search across multiple fields.

But hey! That's no huge issue: when the built-in filters aren't enough, just make your own. Creating custom filters is both fun and... weird! Let's go!

Creating the Filter Class

Over in src/, how about in ApiPlatform, create a new PHP class called CheeseSearchFilter. As usual, this will need to implement an interface or extend some base class. In this case, we need to extend AbstractFilter. Make sure you get the one from Doctrine ORM... it's actually impossible to see which one we have here... so I'll randomly guess:

... lines 1 - 2
namespace App\ApiPlatform;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter;
... lines 6 - 8
class CheeseSearchFilter extends AbstractFilter
{
... lines 11 - 17
}

Hey! I got it! If you chose the wrong one, you'll have to delete your project and start over. Or... just delete this one line and manually say use AbstractFilter to get the right one.

It turns out, creating a filter is different based on the underlying data source. If your underlying data source is Doctrine ORM, creating a filter will look one way. But if your underlying data source is, for example, ElasticSearch - which is something API Platform has built-in support for - then a custom filter will look different. And if you have a completely custom API resource like DailyStats, creating a custom filter looks even another way.

We'll talk about how to create a custom filter for DailyStats in a few minutes.

When we extend AbstractFilter, as you can see, we need to implement a couple of methods. Go to "Code"->"Generate" - or Command + N on a Mac - and select "Implement Methods" to generate the two methods we need:

... lines 1 - 5
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use Doctrine\ORM\QueryBuilder;
class CheeseSearchFilter extends AbstractFilter
{
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
}
public function getDescription(string $resourceClass): array
{
}
}

Checking out Core Filters: PropertyFilter

Filters are ultimately two parts: they're the logic that does the filtering, which is filterProperty(), and then the logic that describes how the filter works, which is used in the documentation. That's the job of getDescription().

Let's start there. To help fill in this method, let's cheat and look at some core classes. Hit Shift+Shift and look for PropertyFilter.php. Make sure to include non-project items. Open that up.

As a reminder, PropertyFilter is an odd filter that is used to return less fields on each result. That's different than most filters whose job is to return less items in the collection.

In PropertyFilter, search for getDescription().

Excellent! So what getDescription() returns is an array of the query parameters that can be used to activate this filter. And under each one, we set a bunch of array keys that are ultimately used to generate documentation for that query parameter.

Unfortunately, nobody likes random arrays like this. And honestly, the best way to see what keys we should fill-in is by checking out these core classes.

Checking out Core Filters: SearchFilter

Close this filter and let's open one more: hit Shift+Shift and look for SearchFilter.php... and make sure to include non-project items.

In this case, getDescription() actually lives in SearchFilterTrait. Hold Command or Control and click to open up that.

This returns the exact same structure, except that it's a bit more complex. It says $properties = $this->getProperties(); and loops over those to create the same array structure we saw earlier - with one query param per property.

In CheeseListing, when you use SearchFilter, you can pass it a properties option that says:

I want this SearchFilter to work on all 4 of these properties

... lines 1 - 18
/**
... lines 20 - 46
* @ApiFilter(SearchFilter::class, properties={
* "title": "partial",
* "description": "partial",
* "owner": "exact",
* "owner.username": "partial"
* })
... lines 53 - 58
class CheeseListing
{
... lines 61 - 217
}

What that effectively does is create four different possible query parameters that you can use. And so the getDescription() method returns an array with 4 items in it.

Close up those core classes.

The $this->properties Property

Now because we're extending AbstractFilter, one of the properties we magically have access to is $this->properties. Let's dd() that here so we can see what it looks like: dd($this->properties):

... lines 1 - 8
class CheeseSearchFilter extends AbstractFilter
{
... lines 11 - 14
public function getDescription(string $resourceClass): array
{
dd($this->properties);
}
}

Back at the browser, open a new tab and go to /api/cheeses.jsonld. But we won't hit the dump yet. Why? Because we haven't told API Platform that we want our CheeseListing to use this filter.

Using the Filter on the Resource

How do we do that? We already know how! Back in CheeseListing, we've done this before with other filters. Anywhere in here, add @ApiFilter() with CheeseSearchFilter::class:

... lines 1 - 19
/**
... lines 21 - 55
* @ApiFilter(CheeseSearchFilter::class)
... lines 57 - 59
*/
class CheeseListing
{
... lines 63 - 219
}

Unfortunately, PhpStorm doesn't auto-complete that for me... so I'll copy the class name and add it on top manually: use App\ApiPlatform\CheeseSearchFilter:

... lines 1 - 10
use App\ApiPlatform\CheeseSearchFilter;
... lines 12 - 221

As soon as we do this, when we refresh the endpoint, even though I don't have any query parameters in the URL... it does hit getDescription()! Why? Because in JSON-LD Hydra, the filter documentation is part of the response.

Apparently, $this->properties is null. Here is where - and when - $this->properties comes into play. Often - and as we've seen - you can pass which properties you want a filter to operate on.

For example, if we wanted our filter to be configurable, we could say properties = and pass it price... or an array of fields:

... lines 1 - 19
/**
... lines 21 - 55
* @ApiFilter(CheeseSearchFilter::class, properties={"price"})
... lines 57 - 59
*/
class CheeseListing
{
... lines 63 - 219
}

As soon as we have this, when we refresh: we get price in the array! So if you want the properties on your filter to be configurable, that is the purpose of $this->properties.

Now, in our case, we're creating this filter entirely for a single class for our application. So we don't need to worry about making it configurable. Remove the properties option from the annotation:

... lines 1 - 19
/**
... lines 21 - 55
* @ApiFilter(CheeseSearchFilter::class)
... lines 57 - 59
*/
class CheeseListing
{
... lines 63 - 219
}

We're going to completely ignore $this->properties... and do whatever we want.

Next, we now know enough to fill in getDescription() for our filter. Once we've done that, we'll bring it to life!

Leave a comment!

10
Login or Register to join the conversation
SC Avatar

Hi,
if I need to decorate the name of the parameter for a nested resource do I need to create a Custom filter or there is a simple way to do so?

#[ApiFilter(SearchFilter::class, properties: ['partnerRestrictionTag.value' => 'exact'])]

I would like to have this parameter as
api/partnerRestriction?restriction=test
and not
api/partnerRestriction?partnerRestrictionTag.value=test

Reply

Hey there,

That's a good question, and after looking at the ApiPlatform documentation, I didn't find a way to configure filter names. I think the only way to do that is by creating a custom filter, unless I missed something

Cheers!

Reply
Anton B. Avatar
Anton B. Avatar Anton B. | posted 2 years ago

If someone have troubles with Absctract Filter - use AbstractContextAwareFilter

Reply

Hey Anton B.

Good tip, but what troubles can be found here?

Cheers|!

Reply
Joel-L Avatar
Joel-L Avatar Joel-L | posted 2 years ago | edited

Hi,
i'm trying to filter on computed values, i created a custom filter


$queryBuilder
            ->addSelect('onHand - onHold) AS stockAvailable ')
            ->andHaving('stockAvailable > 0')
        ;

There is no property stockAvailable just a method :


/**
     * @Groups({"stock:read"})
     */
    public function getStockAvailable(): int
    {
        return $this->onHand - $this->onHold;
    }

but the filter seems not working, i wonder if my case is possible

Reply

Hey Joel L.

First, you will have to create a custom filter and hook it into the ApiPlatform system
https://api-platform.com/do...

Then, you'll have to code the right query to perform the filtering you require. I believe the code you wrote will work, the only thing you're missing is the query identifier you set at the moment of creating a `QueryBuilder`

Let me know if it worked. Cheers!

Reply
Joel-L Avatar

Actually i already try based on this example.
BTW the API Platform doc example is slightly different from the SFCast course. One is extending AbstractContextAwareFilter and the other one AbstractFilter, does it matter ?
I forgot to mention i'm using GraphQL

Reply

Those two abstract classes are almost the same, the only thing that changes is on the apply() method theAbstractContextAwareFilter adds an extra argument to the function, the $context argument, if your filter requires the $context to operate, then you will have to extend from that class, otherwise you can extend from the other one
I'm not sure if all of this applies to GraphQL but I'd assume it does

Reply

Hello,

how can we type these properties in yaml configuration ?

thanks

Reply

Hi Abdelkarim,

If you go to this URL: https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters and click on the YAML tab for its examples, you will find ways to do this using yaml. Hope it helps!

Reply
Cat in space

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

This tutorial also works great with API Platform 2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.4.5", // 2.8.2
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.0", // 5.2.2
        "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.18.7
        "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.8.0
        "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.23.0
        "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.8.0
    }
}
userVoice