Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Filtering Relation Collection

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

Hey, we've made a pretty fancy API! We've got a few sub-resources and embedded relation data, which is readable and writable. This is all super awesome... but it sure does crank up the complexity of our API, especially when it comes to security.

For example, we can no longer see unpublished treasures from the GET collection or GET single endpoints. But we can still see unpublished treasures if you fetch a user and read its dragonTreasures field.

Writing the Test

Let's whip up a test real quick to expose this problem. Open our UserResourceTest. At the bottom, add a public function testUnpublishedTreasuresNotReturned(). Inside that, create a user with UserFactory::createOne(). Then use DragonTreasureFactory to create a treasure that's isPublished false and has its owner set to the $user... just so we know who the owner is.

For the action, say $this->browser()... and we do need to log in to use the endpoint... but we don't care who we're logged in as... so say actingAs() UserFactory::createOne() to log in as someone else.

Then ->get() /api/users/ $user->getId(). Finish with assertJsonMatches() that the length() of dragonTreasures is zero - using a cool length() function from that JMESPath syntax:

... lines 1 - 8
class UserResourceTest extends ApiTestCase
... lines 11 - 68
public function testUnpublishedTreasuresNotReturned(): void
$user = UserFactory::createOne();
'isPublished' => false,
'owner' => $user,
->get('/api/users/' . $user->getId())
->assertJsonMatches('length("dragonTreasures")', 0);

Let's try it! Copy the method... and run it with --filter= that name:

symfony php bin/phpunit --filter=testUnpublishedTreasuresNotReturned

Ok! It expected 1 to be the same as 0 because we are returning the unpublished treasure... but we don't want to!

How Relations are Loaded

First... why is this unpublished DragonTreasure being returned? Didn't we build query extension classes to prevent exactly this?

Well.... an important thing to understand is that these query extension classes are used for the main query on an endpoint only. For example, if we use the GET collection endpoint for treasures, the "main" query is for those treasures and the query collection extension is called.

But when we make a call to a user endpoint - like to GET a single User - API Platform is not making a query for any treasures: it's making a query for that one User. Once it has that User, to get this dragonTreasures field, it does not make another query for those, at least not directly. Instead, if you open the User entity, API Platform - via the serializer - simply calls getDragonTreasures().

So it queries for the User, calls ->getDragonTreasures()... and whatever that returns is set onto the dragonTreasures field. And since this returns all related treasures, that's what we get: including the unpublished ones.

Adding a Filtered Getter Method

How can we fix this? By adding a new method that only returns the published treasures. Say public function getPublishedDragonTreasures(), which returns a Collection. Inside, we can get fancy: return $this->dragonTreasures->filter() passing that a callback with a DragonTreasure $treasure argument. Then, return $treasure->getIsPublished():

... lines 1 - 69
class User implements UserInterface, PasswordAuthenticatedUserInterface
... lines 72 - 216
public function getPublishedDragonTreasures(): Collection
return $this->dragonTreasures->filter(static function (DragonTreasure $treasure) {
return $treasure->getIsPublished();
... lines 223 - 303

That's a nifty trick for looping through all the treasures and getting a shiny new collection with just the published ones.

Side note: one downside to this approach is that if a user has 100 treasures... but only 10 of them are published, internally, Doctrine will first query for all 100... even though we'll only return 10. If you have large collections, this can be a performance problem. In our Doctrine tutorial, we talk about fixing this with something called the Criteria system. But with both approaches, the result is the same: a method that returns a subset of the collection.

Swapping the Getter into our API

At this point, the new method will work, but it's not yet part of our API. Scroll up to the dragonTreasures property. It's currently readable and writable in our API. Make the property only writable:

... lines 1 - 69
class User implements UserInterface, PasswordAuthenticatedUserInterface
... lines 72 - 105
... lines 107 - 108
private Collection $dragonTreasures;
... lines 110 - 305

Then, down on the new method, add #[Groups('user:read')] to make this part of our API and #[SerializedName('dragonTreasures')] to give it the original name:

... lines 1 - 69
class User implements UserInterface, PasswordAuthenticatedUserInterface
... lines 72 - 216
public function getPublishedDragonTreasures(): Collection
... lines 221 - 223
... lines 225 - 305

Drumroll! Try the test:

symfony php bin/phpunit --filter=testUnpublishedTreasuresNotReturned

It explodes! Because... I have a syntax error. Try it again. All green!

And... we're done! You did it! Thank you so much for joining me on this gigantic, cool, challenging journey into API Platform and security. Parts of this tutorial were pretty complex... because I want you to be able to solve real, tough security problems.

In the next tutorial, we're going to look at even more custom and powerful things that you can do with API Platform, including how to use classes for API resources that are not entities.

In the meantime, let us know what you're building and, as always, we're here for you in the comments section. Alright friends, see ya next time!

Leave a comment!

Login or Register to join the conversation
Sebastian-K Avatar
Sebastian-K Avatar Sebastian-K | posted 5 months ago

Wohoo. Done. When can we expect part three?

1 Reply

Hey @Sebastian-K!

Woohoo back! Nice work :). I need to get AssetMapper tutorial out first... part 3 will be the one after that (or MAYBe one other before). There's a bunch of good tutorials in the pipeline that I really want, so I'm hoping to bang through them quickly 🤞


1 Reply
Jeremy Avatar
Jeremy Avatar Jeremy | posted 2 months ago | edited

Woah it kept me going the whole course!
So much better than LOTR! Can't wait for part three to be complete!

Thank you so much for this clear course, it's really really awesome!
Everytime what you explain let me think "woah, but how do you handle X case?", you just continue the video with "You might be asking how to handle X case, let's see together". I have not any unanswered question, this course is so perfect!


Thanks for the comment Jeremy! That is 100% what we're shooting for all around ❤️. Part 3 is quickly on its way!

2 Reply
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.0", // v3.1.2
        "doctrine/annotations": "^2.0", // 2.0.1
        "doctrine/doctrine-bundle": "^2.8", // 2.8.3
        "doctrine/doctrine-migrations-bundle": "^3.2", // 3.2.2
        "doctrine/orm": "^2.14", // 2.14.1
        "nelmio/cors-bundle": "^2.2", // 2.2.0
        "nesbot/carbon": "^2.64", // 2.66.0
        "phpdocumentor/reflection-docblock": "^5.3", // 5.3.0
        "phpstan/phpdoc-parser": "^1.15", // 1.16.1
        "symfony/asset": "6.2.*", // v6.2.5
        "symfony/console": "6.2.*", // v6.2.5
        "symfony/dotenv": "6.2.*", // v6.2.5
        "symfony/expression-language": "6.2.*", // v6.2.5
        "symfony/flex": "^2", // v2.2.4
        "symfony/framework-bundle": "6.2.*", // v6.2.5
        "symfony/property-access": "6.2.*", // v6.2.5
        "symfony/property-info": "6.2.*", // v6.2.5
        "symfony/runtime": "6.2.*", // v6.2.5
        "symfony/security-bundle": "6.2.*", // v6.2.6
        "symfony/serializer": "6.2.*", // v6.2.5
        "symfony/twig-bundle": "6.2.*", // v6.2.5
        "symfony/ux-react": "^2.6", // v2.7.1
        "symfony/ux-vue": "^2.7", // v2.7.1
        "symfony/validator": "6.2.*", // v6.2.5
        "symfony/webpack-encore-bundle": "^1.16", // v1.16.1
        "symfony/yaml": "6.2.*" // v6.2.5
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.4", // 3.4.2
        "mtdowling/jmespath.php": "^2.6", // 2.6.1
        "phpunit/phpunit": "^9.5", // 9.6.3
        "symfony/browser-kit": "6.2.*", // v6.2.5
        "symfony/css-selector": "6.2.*", // v6.2.5
        "symfony/debug-bundle": "6.2.*", // v6.2.5
        "symfony/maker-bundle": "^1.48", // v1.48.0
        "symfony/monolog-bundle": "^3.0", // v3.8.0
        "symfony/phpunit-bridge": "^6.2", // v6.2.5
        "symfony/stopwatch": "6.2.*", // v6.2.5
        "symfony/web-profiler-bundle": "6.2.*", // v6.2.5
        "zenstruck/browser": "^1.2", // v1.2.0
        "zenstruck/foundry": "^1.26" // v1.28.0