Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Lucky you! You found an early release chapter - it will be fully polished and published shortly!

Conditional Fields by User: ApiProperty

This Chapter isn't
quite ready...

Rest assured, the gnomes are hard at work
completing this video!

Browse Tutorials

We control which fields are readable and writable via serialization groups. But what if you have a field that should be included in the API... but only for certain users? Sadly, groups can't pull off that kind of magic on their own.

For example, find the isPublished field and let's make this part of our API by adding the treasure:read and treasure:write groups.

Now if we spin over and try the tests:

symfony php bin/phpunit

This makes one test fail: testGetCollectionOfTreasures sees that isPublished is being returned... and it's not expecting it.

Here's the plan: we'll sneak the field into our API but only for admin users or owners of this DragonTreasure. How can we pull that off?

Hello ApiProperty

Well, surprise! We don't often need it, but we can add an ApiProperty attribute above any property to help further configure it. It has a bunch of stuff, like a description that helps with your documentation and many edge-case things. There's even one called readable. If we said readable: false... then the serialization groups would say that this should be included in the response... but then this would override that. Watch: if we try the tests:

symfony php bin/phpunit

They pass because the field is gone.

The security Option

For our mission, we can leverage a super cool option called security. Set it to is_granted("ROLE_ADMIN").

That's it! If this expression return false, isPublished will not be included in the API: it won't be readable or writable.

And when we run the tests now:

symfony php bin/phpunit

They still pass, which means isPublished is not being returned.

Now let's go test the "happy" path where this field is returned. Pop open DragonTreasureResourceTest. Here's the original test: testGetCollectionOfTreasures. We're anonymous, so isPublished isn't returned.

Now scroll down to testAdminCanPatchToEditTreasure. When we create the DragonTreasure, let's make sure it always starts with isPublished false.

Then, down here, assertJsonMatches('isPublished', false) to test that the field is returned.

Copy the test name, spin over and add --filter to run just that test:

symfony php bin/phpunit --filter=testAdminCanPatchToEditTreasure

And... it passes! The field is returned when we're an admin.

Also Returning isPublished for the Owner

What about if we're the owner of the treasure? Copy the test... rename it to testOwnerCanSeeIsPublishedField()... and let's tweak a few things. Rename $admin to $user, simplify this to DragonTreasureFactory::createOne() and make sure the owner is set to our new $user.

We could change this to a GET request... but PATCH is fine. In either situation, we want to make sure the isPublished field is returned.

Since we haven't implemented this yet... let's make sure it fails. Copy the method name and try it:

symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField

Failure achieved! And we know how to solve this! On the security option, we could inline the logic with or object.getOwner() === user. But remember: we created the voter so that we don't need to do crazy stuff like that! Instead, say is_granted(), EDIT then object.

Try the test now:

symfony php bin/phpunit --filter=testOwnerCanSeeIsPublishedField

The Special securityPostDenormalize

Got it! Oh, and I haven't used it much, but there's also a securityPostDenormalize option. Just like with the securityPostDenormalize option on each operation, this runs after the new data is deserialized onto the object. What's interesting is that if the expression returns false, the data on the object is actually reverted.

For example, suppose the isPublished property started as false and then the user sent some JSON to change it to true. But then, securityPostDenormalize returned false. In that case, API Platform will revert the isPublished property back to its original value: it will change it from false back to true. Oh, and by the way, securityPostDenormalize is not executed on GET requests: it only happens when data is being deserialized. So be sure to put your main security logic in security and only use securityPostDenormalize if you need it.

Up next on our to-do list: let's level-up our user operations to hash the password before saving to the database. We'll need a fresh, non-persisted plain password property to make it happen.

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.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