If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeLet'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.
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.
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.
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.
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.
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.
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 | |
} |
Tip
You should also pass $context
as the 3rd argument to $this->pagination->getPagination()
.
This is used to correctly calculate the current page.
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.
Hey Bernard A.!
Ah, excellent catch! Yes, it looks like you could grab that and do all the processing you want. However, if you wanted to do any "cleaning up" of the filter (it's minor, but we convert it from a string to a DateTimeImmutable), you could still do that in your filter and pass *that* forward.
Cheers!
Also wondering why you chose to use a setter for setting the date on DailyStatsPaginator
and making it non-nullable, when you could both make it nullable and use it as a constructor argument instead. I do understand that it doesn't make any difference in the end, but just wondering what the reason for this inconsistency might be. :)
Yo Adeoweb!
Hmm, fair question :). I think in either case, the property would need to be nullable because the "from date" is optional: it's only present the query parameter is present: https://symfonycasts.com/screencast/api-platform-extending/apply-filter#codeblock-10bf80c57d. So then - unless I'm forgetting a detail - since it needs to nullable either way, it comes down to a pure preference. The setter is a bit more expressive than adding a 4th constructor argument... however you could also use named parameters in the constructor if you wanted the new DailyStatsPaginator()
code to be a bit more readable. So, yea, just coming down to preference here.
Cheers!
I just noticed while debugging that at least in Api Platform 2.6, the $this->pagination->getPagination()
call in DailyStatsProvider
line 28 actually needs a third argument – $context
. Without it, $page
is always 1.
Hey Adeoweb!
Ah, good catch! I bet that's a change in API Platform - I'll check it out and we'll add a note if needed (it sounds like it is needed).
Cheers!
Thanks for your replies. It seems like this was changed while preparing to release 2.4.0, in this commit.
// 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
}
}
Passing info from Filter to DataProvider as below seems unnecessary.
$context[self::FROM_FILTER_CONTEXT] = $fromDate;
The same information is available on the Context on the DataProvider ( dump below):
See the key "filters" below: