Custom Filter apply()

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 talk about how filter classes work internally. As we know, each data provider is 100% responsible for taking filters into account and changing the data it returns based on them. So, filtering happens inside each data provider, not via some magic system that runs after them.

How Filters work in Doctrine

Let's look at how this is done in the core Doctrine data provider. Hit Shift+Shift, search for doctrinedataprovider and include non project items. There it is: CollectionDataProvider from Orm\. Here is the getCollection() method.

The Doctrine data provider has a system called "collection extensions": these are hook points that allow you to modify the query in any way you want. And... we actually created one of these extensions in the last tutorial: CheeseListingIsPublishedExtension:

... lines 1 - 11
class CheeseListingIsPublishedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
private $security;
public function __construct(Security $security)
{
$this->security = $security;
}
public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null)
{
$this->addWhere($queryBuilder, $resourceClass);
}
public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = [])
{
$this->addWhere($queryBuilder, $resourceClass);
}
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
if ($resourceClass !== CheeseListing::class) {
return;
}
if ($this->security->isGranted('ROLE_ADMIN')) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
if (!$this->security->getUser()) {
$queryBuilder->andWhere(sprintf('%s.isPublished = :isPublished', $rootAlias))
->setParameter('isPublished', true);
} else {
$queryBuilder->andWhere(sprintf('
%s.isPublished = :isPublished
OR %s.owner = :owner',
$rootAlias, $rootAlias
))
->setParameter('isPublished', true)
->setParameter('owner', $this->security->getUser());
}
}
}

This modifies the query so that we don't return unpublished listings, unless you're the owner of the listing or an admin.

Doctrine's FilterExtension

Why are we talking about these extension classes? Because one of the core Doctrine extensions is called FilterExtension.

Let's open it up: Shirt+Shift and look for FilterExtension.php making sure to include all non-project items. Get the one from Orm\. I love this. It loops over all of the filters that have been activated for this resource class, calls apply() on each one and passes it the QueryBuilder!

Thanks to this, in CheeseSearchFilter, all we needed to do was extend AbstractFilter and fill in the filterProperty() method:

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

The apply() method lives in AbstractFilter, which does some work and then ultimately calls filterProperty().

The point is: the Doctrine filter system works via a Doctrine extension, which knows to call a method on each filter object.

How / When the apply() Method is Called

But all of this stuff will not happen in our situation... because we are not using the Doctrine data provider. However, because we made our filter implement the FilterInterface from the core Serializer\ namespace, API Platform will help us a bit:

... lines 1 - 4
use ApiPlatform\Core\Serializer\Filter\FilterInterface;
... lines 6 - 7
class DailyStatsDateFilter implements FilterInterface
{
... lines 10 - 26
}

How? By automatically calling our apply() method on every request for an API resource where our filter has been activated:

... lines 1 - 7
class DailyStatsDateFilter implements FilterInterface
{
public function apply(Request $request, bool $normalization, array $attributes, array &$context)
{
}
... lines 13 - 26
}

What I mean is, in DailyStats we added @ApiFilter(DailyStatsDateFilter::class):

... lines 1 - 11
/**
... lines 13 - 22
* @ApiFilter(DailyStatsDateFilter::class)
*/
class DailyStats
{
... lines 27 - 58
}

Thanks to this, whenever we make a request to a DailyStats operation, API Platform will automatically call the DailyStatsDateFilter::apply() method:

... lines 1 - 7
class DailyStatsDateFilter implements FilterInterface
{
public function apply(Request $request, bool $normalization, array $attributes, array &$context)
{
}
... lines 13 - 26
}

This works via a context builder in the core of API Platform that loops over all of the filters for the current resource class, checks to see if they implement the FilterInterface that we're using and, if they do, calls apply().

So... this is huge! It means that API Platform is smart enough to automatically call our filter's apply() method but only when needed. This means that we can get down to work.

Grabbing the Query Parameter

Our first job is to read the query parameter from the URL. And... hey! We get the Request object as an argument:

... lines 1 - 5
use Symfony\Component\HttpFoundation\Request;
class DailyStatsDateFilter implements FilterInterface
{
public function apply(Request $request, bool $normalization, array $attributes, array &$context)
{
}
... lines 13 - 26
}

Schweet! Let's dd($request->query->all()):

... lines 1 - 7
class DailyStatsDateFilter implements FilterInterface
{
public function apply(Request $request, bool $normalization, array $attributes, array &$context)
{
dd($request->query->all());
}
... lines 14 - 27
}

Back at your browser, refresh and... there it is: the from query param.

Grab that with $from = $request->query->get('from'). And, if not $from, it means we should do no filtering. Return without doing anything. After, dd($from):

... lines 1 - 7
class DailyStatsDateFilter implements FilterInterface
{
public function apply(Request $request, bool $normalization, array $attributes, array &$context)
{
$from = $request->query->get('from');
if (!$from) {
return;
}
dd($from);
}
... lines 20 - 33
}

Refresh now and... yay! We have a date string.

Passing Info from the Filter to the Data Provider

So... what do we do with that string? I mean, we're not inside DailyStatsProvider where we actually need this info: we're way over here in the filter class.

The answer is that we're going top pass this info from the filter to the data provider via the $context. Check it out: one of the arguments to apply() is the $context array and it's passed by reference:

... lines 1 - 7
class DailyStatsDateFilter implements FilterInterface
{
public function apply(Request $request, bool $normalization, array $attributes, array &$context)
{
... lines 12 - 18
}
... lines 20 - 33
}

That means we can modify it.

Head to the top of this class and add a new public constant, how about: FROM_FILTER_CONTEXT set to daily_stats_from. This will be the key we set on $context:

... lines 1 - 7
class DailyStatsDateFilter implements FilterInterface
{
public const FROM_FILTER_CONTEXT = 'daily_stats_from';
... lines 11 - 40
}

Before we do that, let's convert the string into a DateTime object: $fromDate = \DateTimeImmutable::createFromFormat() passing Y-m-d as the format and then $from:

... lines 1 - 7
class DailyStatsDateFilter implements FilterInterface
{
... lines 10 - 11
public function apply(Request $request, bool $normalization, array $attributes, array &$context)
{
... lines 14 - 15
if (!$from) {
return;
}
$fromDate = \DateTimeImmutable::createFromFormat('Y-m-d', $from);
... lines 21 - 25
}
... lines 27 - 40
}

We're using createFromFormat() because if the $from string is in an invalid format, it will return false. We can use that to code defensively: if $fromDate, then we know we have a valid date. Also add $fromDate = $fromDate->setTime() and pass zero, zero, zero to normalize all the dates to midnight:

... lines 1 - 7
class DailyStatsDateFilter implements FilterInterface
{
... lines 10 - 11
public function apply(Request $request, bool $normalization, array $attributes, array &$context)
{
... lines 14 - 19
$fromDate = \DateTimeImmutable::createFromFormat('Y-m-d', $from);
if ($fromDate) {
$fromDate = $fromDate->setTime(0, 0, 0);
... line 24
}
}
... lines 27 - 40
}

Finally, set this on the context: $context[self::FROM_FILTER_CONTEXT] = $fromDate:

... lines 1 - 7
class DailyStatsDateFilter implements FilterInterface
{
... lines 10 - 11
public function apply(Request $request, bool $normalization, array $attributes, array &$context)
{
... lines 14 - 19
$fromDate = \DateTimeImmutable::createFromFormat('Y-m-d', $from);
if ($fromDate) {
$fromDate = $fromDate->setTime(0, 0, 0);
$context[self::FROM_FILTER_CONTEXT] = $fromDate;
}
}
... lines 27 - 40
}

So the job of the apply() method in a custom, non-Doctrine filter is not actually to apply the filtering logic: it's to pass some filtering info into the context.

Reading the Context in the Data Provider

And now, we're dangerous. Well... we're almost dangerous. If we can get access to the $context from inside DailyStatsProvider, then we can read that key off and set the from date. Unfortunately, we do not have the context yet:

... lines 1 - 13
class DailyStatsProvider implements CollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 16 - 24
public function getCollection(string $resourceClass, string $operationName = null)
{
... lines 27 - 36
}
... lines 38 - 47
}

But fortunately, we know how to get it!

Instead of CollectionDataProviderInterface, implement ContextAwareCollectionDataProviderInterface:

... lines 1 - 5
use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface;
... lines 7 - 14
class DailyStatsProvider implements ContextAwareCollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 17 - 49
}

The only difference is that getCollection() now has an extra array $context = [] argument:

... lines 1 - 14
class DailyStatsProvider implements ContextAwareCollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 17 - 25
public function getCollection(string $resourceClass, string $operationName = null, array $context = [])
{
... lines 28 - 38
}
... lines 40 - 49
}

To start, let's dd($context) and see if the filter info is there:

... lines 1 - 14
class DailyStatsProvider implements ContextAwareCollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 17 - 25
public function getCollection(string $resourceClass, string $operationName = null, array $context = [])
{
dd($context);
... lines 29 - 38
}
... lines 40 - 49
}

Ok, refresh. And... we got it! The daily_stats_from is there! And if we take the from query param off, it still works, but the key is gone.

Let's finally use this. Remove the dd() and, down here, add $fromDate = $context[DailyStatsDateFilter::FROM_FILTER_CONTEXT] with a ?? null so that is defaults to null if the key doesn't exist:

... lines 1 - 10
use App\ApiPlatform\DailyStatsDateFilter;
... lines 12 - 15
class DailyStatsProvider implements ContextAwareCollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 18 - 26
public function getCollection(string $resourceClass, string $operationName = null, array $context = [])
{
... lines 29 - 30
$paginator = new DailyStatsPaginator(
$this->statsHelper,
$page,
$limit
);
$fromDate = $context[DailyStatsDateFilter::FROM_FILTER_CONTEXT] ?? null;
... lines 38 - 42
}
... lines 44 - 53
}

Then, if we have a $fromDate, call $paginator->setFromDate() and pass it there:

... lines 1 - 15
class DailyStatsProvider implements ContextAwareCollectionDataProviderInterface, ItemDataProviderInterface, RestrictedDataProviderInterface
{
... lines 18 - 26
public function getCollection(string $resourceClass, string $operationName = null, array $context = [])
{
... lines 29 - 36
$fromDate = $context[DailyStatsDateFilter::FROM_FILTER_CONTEXT] ?? null;
if ($fromDate) {
$paginator->setFromDate($fromDate);
}
... lines 41 - 42
}
... lines 44 - 53
}

Testing time! The query parameter should filter from 09-01. Refresh and... it does! We only get three results starting from that date! If we take off the query param... we get everything.

We just built a completely custom filter. Great work team! Next, in DailyStatsDateFilter, if the from data is in an invalid format, we decided to ignore it:

... lines 1 - 7
class DailyStatsDateFilter implements FilterInterface
{
... lines 10 - 11
public function apply(Request $request, bool $normalization, array $attributes, array &$context)
{
... lines 14 - 19
$fromDate = \DateTimeImmutable::createFromFormat('Y-m-d', $from);
if ($fromDate) {
$fromDate = $fromDate->setTime(0, 0, 0);
$context[self::FROM_FILTER_CONTEXT] = $fromDate;
}
}
... lines 27 - 40
}

But we could also decide that we want to return a 400 error instead.

Let's see how to do that and how we could even make that behavior configurable. This will lead us down a path towards true filter enlightenment and uncovering a hidden secret. Basically, we're going to learn even more about the power behind filters.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.0 || ^8.0",
        "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.9.10
        "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
    }
}