Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Decorating the CollectionProvider

Video not working?

It looks like your browser may not support the H264 codec. If you're using Linux, try a different browser or try installing the gstreamer0.10-ffmpeg gstreamer0.10-plugins-good packages.

Thanks! This saves us from needing to use Flash or encode videos in multiple formats. And that let's us get back to making more videos :). But as always, please feel free to message us.

Let's boldly do something that scares most us developers: run the entire test suite:

symfony php bin/phpunit

These were obediently passing when I started the tutorial... but they've decided to rebel! Let's pop open the failed response. Hmm:

More than one result was found for query, although one row or none was expected.

If you view the page source, this is coming from Doctrine... and eventually the core ItemProvider that we're calling. Back on the docs, the GetCollection operation - which is the operation used in this test - has a different provider: CollectionProvider.

Unfortunately, when I set provider inside the #[ApiResource] attribute... that set the provider for every operation. It is possible to set the provider for a specific operation... like this. But... I like having a single provider for my entire API resource - it's simpler.

To make that happen, we just need to realize that this provider will be called both when fetching a single item and when fetching a collection of items. For this test, our provider is being called to fetch a collection... then we're calling the item provider... and weird stuff happens.

dd() the $operation again...

... lines 1 - 11
class DragonTreasureStateProvider implements ProviderInterface
... lines 14 - 20
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
... lines 24 - 32

then copy the failing test name... and run just that one:

symfony php bin/phpunit --filter=testGetCollectionOfTreasures

Excellent! A GetCollection object. We can use that to figure out which provider we need!

Let's get the core CollectionProvider injected. Copy the first argument, duplicate it, and set it to use the CollectionProvider service from ORM. Name it $collectionProvider.

... lines 1 - 4
use ApiPlatform\Doctrine\Orm\State\CollectionProvider;
... lines 6 - 13
class DragonTreasureStateProvider implements ProviderInterface
public function __construct(
... line 17
#[Autowire(service: CollectionProvider::class)] private ProviderInterface $collectionProvider,
... line 19
... lines 23 - 39

Below, check to see if $operation is an instance of CollectionOperationInterface. Ok, really, only one operation - GetCollection - uses the collection provider... but in case a custom operation were added, anything that needs a collection will implement this interface. In this situation, return $this->collectionProvider->provide() and pass in the args. And... don't forget the method name!

... lines 1 - 23
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
if ($operation instanceof CollectionOperationInterface) {
return $this->collectionProvider->provide($operation, $uriVariables, $context);
... lines 29 - 38
... lines 40 - 41

Alrighty! Spin over or run the test again:

symfony php bin/phpunit --filter=testGetCollectionOfTreasures

And... it still explodes. Something about expected null to be the same as 5. Check the response. Ah! It's our error again! For the item operation, we are setting that property. Now, we need to do the same thing here: loop over each treasure and set that.

The Paginator Object

But first, what does the collection provider return - an array of treasures? Copy the entire call, dd() it... and run the test again:

... lines 1 - 23
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
if ($operation instanceof CollectionOperationInterface) {
dd($this->collectionProvider->provide($operation, $uriVariables, $context));
... line 28
... lines 30 - 39
... lines 41 - 42
symfony php bin/phpunit --filter=testGetCollectionOfTreasures

Let's see... it's a Paginator object! That's important: that is what powers the pagination for our collection endpoints. Ok, it's not actually that important right now - we can loop over this object to get each DragonTreasure - but we'll come back to this later when we create a custom resource.

Delete the dd() and, instead of the return, say $paginator equals. I'll help my editor by saying that this is an iterable of DragonTreasure. Now, foreach $paginator as $treasure... and then I'll steal the code from below... and paste.

Now that we've modified each item, return $paginator.

... lines 1 - 23
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
if ($operation instanceof CollectionOperationInterface) {
/** @var $paginator iterable<DragonTreasure> */
$paginator = $this->collectionProvider->provide($operation, $uriVariables, $context);
foreach ($paginator as $treasure) {
$treasure->setIsOwnedByAuthenticatedUser($this->security->getUser() === $treasure->getOwner());
return $paginator;
... lines 36 - 45
... lines 47 - 48

Let's try it again!

symfony php bin/phpunit --filter=testGetCollectionOfTreasures

It fails again... but at the very end: DragonTreasureResourceTest line 37. Let's go check that out. So all the way up here, we create some treasures, make a ->get() request to the collection endpoint, verify some things, and then, below, we grab the first item and check to make sure it has the right fields. Apparently the isMine property is there... but wasn't expected?

That's my bad. On a previous adventure, when we added the isMine property, we only added it when it was true. If a DragonTreasure did not belong to me, the field wasn't there at all... and it probably should have been. So let's update the test. And now... it's green!

... lines 1 - 13
class DragonTreasureResourceTest extends ApiTestCase
... lines 16 - 18
public function testGetCollectionOfTreasures(): void
... lines 21 - 35
$this->assertSame(array_keys($json->decoded()['hydra:member'][0]), [
... lines 37 - 45
... lines 49 - 218

Re-run everything

symfony php bin/phpunit

POST: No State Provider

Uhhh. down to one failure: testPostToCreateTreasure - with a 500 error. Pop that open in our browser. Bah! It's our:

You must call setIsOwnedByAuthenticatedUser().

But how is that possible? No matter what, we are setting that value inside our state provider! However... the POST operation is unique: it's the only operation that does not use a provider. Ok, Delete doesn't show a provider, but it uses the ItemProvider to load the one item it's about to delete.

For Post, the JSON is deserialized directly into a TreasureEntity.. then saved. The state provider is never needed or used.... which means when it serializes to JSON, that property is still not set.

The fix is in the state processor for DragonTreasure: right before or after saving, we need to run this same logic. Copy this. We do have a state processor already for DragonTreasure. It's meant to set the owner if it's not set... but let's hijack it for this. Right after the save, paste that. Oh, but the way we created this in the previous episode means that it's called for every ApiResource. So we need the same if statement from up here: if $data is an instanceof DragonTreasure, then set that property. I'll... update a couple of variables.

... lines 1 - 11
class DragonTreasureSetOwnerProcessor implements ProcessorInterface
... lines 14 - 17
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): void
... lines 20 - 25
if ($data instanceof DragonTreasure) {
$data->setIsOwnedByAuthenticatedUser($data->getOwner() === $this->security->getUser());

So, the object saves, we set the property... and then it's serialized to JSON. Try those tests again:

symfony php bin/phpunit

All green! Woo! So we already know that we can run code before or after an item saves by having a custom state processor. But what if we need to run code only when something specific changes? Like when a DragonTreasure changes from unpublished to published. We'll dive into that next, starting with making our state processor a bit simpler.

Leave a comment!

Login or Register to join the conversation
Cat in space

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

What PHP libraries does this tutorial use?

// composer.json
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "3.1.x-dev", // 3.1.x-dev
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.10.2
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.4
        "doctrine/orm": "^2.14", // 2.16.1
        "nelmio/cors-bundle": "^2.2", // 2.3.1
        "nesbot/carbon": "^2.64", // 2.69.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.23.1
        "symfony/asset": "6.3.*", // v6.3.0
        "symfony/console": "6.3.*", // v6.3.2
        "symfony/dotenv": "6.3.*", // v6.3.0
        "symfony/expression-language": "6.3.*", // v6.3.0
        "symfony/flex": "^2", // v2.3.3
        "symfony/framework-bundle": "6.3.*", // v6.3.2
        "symfony/property-access": "6.3.*", // v6.3.2
        "symfony/property-info": "6.3.*", // v6.3.0
        "symfony/runtime": "6.3.*", // v6.3.2
        "symfony/security-bundle": "6.3.*", // v6.3.3
        "symfony/serializer": "6.3.*", // v6.3.3
        "symfony/stimulus-bundle": "^2.9", // v2.10.0
        "symfony/string": "6.3.*", // v6.3.2
        "symfony/twig-bundle": "6.3.*", // v6.3.0
        "symfony/ux-react": "^2.6", // v2.10.0
        "symfony/ux-vue": "^2.7", // v2.10.0
        "symfony/validator": "6.3.*", // v6.3.2
        "symfony/webpack-encore-bundle": "^2.0", // v2.0.1
        "symfony/yaml": "6.3.*", // v6.3.3
        "symfonycasts/micro-mapper": "^0.1.0" // v0.1.1
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.4
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.11
        "symfony/browser-kit": "6.3.*", // v6.3.2
        "symfony/css-selector": "6.3.*", // v6.3.2
        "symfony/debug-bundle": "6.3.*", // v6.3.2
        "symfony/maker-bundle": "^1.48", // v1.50.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.3.2
        "symfony/stopwatch": "6.3.*", // v6.3.0
        "symfony/web-profiler-bundle": "6.3.*", // v6.3.2
        "zenstruck/browser": "^1.2", // v1.4.0
        "zenstruck/foundry": "^1.26" // v1.35.0