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 SubscribeWe just learned that if you pass an arguments
option to your annotation:
... lines 1 - 11 | |
/** | |
... lines 13 - 22 | |
* @ApiFilter(DailyStatsDateFilter::class, arguments={"throwOnInvalid"=true}) | |
*/ | |
class DailyStats | |
{ | |
... lines 27 - 58 | |
} |
Then the keys inside get mapped as arguments to the constructor of your filter class:
... lines 1 - 8 | |
class DailyStatsDateFilter implements FilterInterface | |
{ | |
... lines 11 - 14 | |
public function __construct(bool $throwOnInvalid = false) | |
{ | |
... line 17 | |
} | |
... lines 19 - 53 | |
} |
But there's more going on than it seems. Obviously, someone instantiates our filter object... and it's a pretty good guess that API Platform does it directly and uses the arguments
option to figure out what arguments it should pass to the constructor.
But actually, our filter is a service in the container! We didn't register it directly, API Platform did thanks to our annotation. Each time it sees an ApiFilter
annotation, it registers a unique service for that filter class.
Then, after registering it as a service, it takes the arguments
option and sets those as named arguments
on that service. Why do we care about how our filter objects are instantiated? Because when API Platform registers the service, it also sets it to autowire: true
. Yep, this means we can access services in our filter class just like we normally would!
Check it out: add a LoggerInterface $logger
argument:
... lines 1 - 5 | |
use Psr\Log\LoggerInterface; | |
... lines 7 - 9 | |
class DailyStatsDateFilter implements FilterInterface | |
{ | |
... lines 12 - 16 | |
public function __construct(LoggerInterface $logger, bool $throwOnInvalid = false) | |
{ | |
... lines 19 - 20 | |
} | |
... lines 22 - 58 | |
} |
I'm adding it as the first argument just to, kind of, prove that the order doesn't matter. Create the $logger
property and then add $this->logger = $logger
:
... lines 1 - 9 | |
class DailyStatsDateFilter implements FilterInterface | |
{ | |
... lines 12 - 13 | |
private $logger; | |
... lines 15 - 16 | |
public function __construct(LoggerInterface $logger, bool $throwOnInvalid = false) | |
{ | |
$this->logger = $logger; | |
... line 20 | |
} | |
... lines 22 - 58 | |
} |
Now, down in apply()
, we can say $this->logger->info(sprintf())
, Filtering from date "%s"
and pass $from
:
... lines 1 - 9 | |
class DailyStatsDateFilter implements FilterInterface | |
{ | |
... lines 12 - 22 | |
public function apply(Request $request, bool $normalization, array $attributes, array &$context) | |
{ | |
... lines 25 - 37 | |
if ($fromDate) { | |
$this->logger->info(sprintf('Filtering from date "%s"', $from)); | |
... lines 40 - 42 | |
} | |
} | |
... lines 45 - 58 | |
} |
Let's see if it works! Move over and go back to use a real from date. Refresh and... ok! No error! Now open a new tab and go to /_profiler
. Find the 200 status, click the token link and go down to Logs. Got it!
Oh, but it logged twice? That's actually ok. The system that calls the apply()
method on our filter is the "serialization context builder" system, a system we hooked into before. In the src/Serializer/
directory, in the last tutorial, we created an AdminGroupsContextBuilder
.
Anyways, the context builder system is called two times: once at the beginning when it's reading the data and again later when the data is serialized. Hence, we see two logs.
Anyways, all of this stuff about the arguments
option and autowiring applies equally to an entity filter like our CheeseSearchFilter
.
For example, inside of this class, we use LIKE
in our query instead of equals:
... lines 1 - 8 | |
class CheeseSearchFilter extends AbstractFilter | |
{ | |
protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) | |
{ | |
... lines 13 - 19 | |
$queryBuilder->andWhere(sprintf('%s.title LIKE :%s OR %s.description LIKE :%s', $alias, $valueParameter, $alias, $valueParameter)) | |
->setParameter($valueParameter, '%'.$value.'%'); | |
} | |
... lines 23 - 36 | |
} |
Let's pretend that we want to make this configurable: you can decide if you want a fuzzy or exact search.
Open up src/Entity/CheeseListing.php
and find that filter. Let's add arguments={}
and invent a new one called useLike
set to true
:
... lines 1 - 19 | |
/** | |
... lines 21 - 55 | |
* @ApiFilter(CheeseSearchFilter::class, arguments={"useLike"=true}) | |
... lines 57 - 59 | |
*/ | |
class CheeseListing | |
{ | |
... lines 63 - 219 | |
} |
Then, over in a browser, close the profiler and head to /api/cheeses.jsonld
. Yep, we immediately get the error we expect:
CheeseSearchFilter
does not have argument$useLike
Let's go add it! The only weird part in an entity filter is that our parent class already has a constructor. This means we can't just add a new constructor, we need to override the existing one.
To do that, go to "Code"->"Generate" - or Command
+ N
on a Mac - select "Override Methods" and choose __construct()
:
... lines 1 - 7 | |
use Doctrine\Persistence\ManagerRegistry; | |
use Psr\Log\LoggerInterface; | |
use Symfony\Component\HttpFoundation\RequestStack; | |
use Symfony\Component\Serializer\NameConverter\NameConverterInterface; | |
class CheeseSearchFilter extends AbstractFilter | |
{ | |
public function __construct(ManagerRegistry $managerRegistry, ?RequestStack $requestStack = null, LoggerInterface $logger = null, array $properties = null, NameConverterInterface $nameConverter = null) | |
{ | |
parent::__construct($managerRegistry, $requestStack, $logger, $properties, $nameConverter); | |
} | |
... lines 19 - 45 | |
} |
Oh, wow: that is a big constructor... which is fine. But we can slim it down a bit. We do need ManagerRegistry
, we do need RequestStack
, but we don't need to pass the $logger
to the parent class: it's not even used there. And you only need the $properties
argument if you actually need the properties
option on the annotation, or if you allow the filter to be used on a property. Since we don't have this use-case, we don't need it:
... lines 1 - 12 | |
class CheeseSearchFilter extends AbstractFilter | |
{ | |
public function __construct(ManagerRegistry $managerRegistry, NameConverterInterface $nameConverter = null) | |
{ | |
... line 17 | |
} | |
... lines 19 - 45 | |
} |
Ok! Our constructor now looks a bit nicer. For the parent construct call, pass null
for the logger, and null
for $properties
:
... lines 1 - 12 | |
class CheeseSearchFilter extends AbstractFilter | |
{ | |
public function __construct(ManagerRegistry $managerRegistry, NameConverterInterface $nameConverter = null) | |
{ | |
parent::__construct($managerRegistry, null, null, [], $nameConverter); | |
} | |
... lines 19 - 45 | |
} |
If we go right now and refresh... we get the same error... but now we can add that argument. Back in the filter, I'm going to be tricky and add this as the second argument - bool $useLike = false
- to prove that order don't matter:
... lines 1 - 12 | |
class CheeseSearchFilter extends AbstractFilter | |
{ | |
... lines 15 - 16 | |
public function __construct(ManagerRegistry $managerRegistry, bool $useLike = false, NameConverterInterface $nameConverter = null) | |
{ | |
... lines 19 - 22 | |
} | |
... lines 24 - 50 | |
} |
Each argument is passed via autowiring or by the named arguments matching.
Put your cursor back on the argument, hit Alt
+Enter
and go to "Initialize properties" to create the $useLike
property and set it:
... lines 1 - 12 | |
class CheeseSearchFilter extends AbstractFilter | |
{ | |
private $useLike; | |
public function __construct(ManagerRegistry $managerRegistry, bool $useLike = false, NameConverterInterface $nameConverter = null) | |
{ | |
// todo - actually use this | |
$this->useLike = $useLike; | |
... lines 21 - 22 | |
} | |
... lines 24 - 50 | |
} |
And... that's it! I'm going to stop right there and not actually use the $useLike
property to change the query... because that would be pretty boring.
As long as it doesn't explode, we know it's working. Refresh and... yes! If we add ?search=cube
... that still works too!
Next, let's talk about something totally different: API Platform's super-cool output DTO system: a middle-ground between adding custom properties to your entity and creating totally custom API resource classes.
"Houston: no signs of life"
Start the conversation!
// 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
}
}