Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Other Conditional Field Strategies

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

Let's keep playing with how we can hide or show fields. Remove the #[ApiProperty] attribute. Then, on top, set the normalizationContext option. We used this in previous tutorials... but this time, instead of groups, set a key called AbstractNormalizer::IGNORED_ATTRIBUTES and then set that to an array. Inside, put flameThrowingDistance.

... lines 1 - 24
class UserApi
... lines 27 - 44
#[ApiProperty(readable: false, writable: false)]
public int $flameThrowingDistance = 0;

Whether a field is readable or writable really comes down to the serializer. This tells the serializer:

Yo! When you're normalizing - so going to JSON - ignore this property.

This should make it writable, but not readable. When we try it...

symfony php bin/phpunit --filter=testPostToCreateUser

That's exactly what happens! To wrap it in a "do not write" sign, duplicate this move with denormalizationContext.

... lines 1 - 10
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
... lines 12 - 15
... line 17
normalizationContext: [AbstractNormalizer::IGNORED_ATTRIBUTES => ['flameThrowingDistance']],
denormalizationContext: [AbstractNormalizer::IGNORED_ATTRIBUTES => ['flameThrowingDistance']],
... lines 20 - 23
... lines 25 - 27
class UserApi
... lines 30 - 48

Copy that, put a "de" on the front of it, and now when we try it:

symfony php bin/phpunit --filter=testPostToCreateUser

Yup! flameThrowingDistance is "1" - so it is not writable, and down here... it's not readable either. Sweet.

So this is just a different option that should work the same as ApiProperty... though I have seen complex cases where this context option worked when the ApiProperty solution did not. Anyway, delete those.

The #[Ignore] Attribute

The last way to ignore a field - if you want to ignore it completely - is to add an attribute called... #[Ignore]! This comes from Symfony's serializer system.

... lines 1 - 10
use Symfony\Component\Serializer\Annotation\Ignore;
... lines 12 - 25
class UserApi
... lines 28 - 45
public int $flameThrowingDistance = 0;

When we try the test:

symfony php bin/phpunit --filter=testPostToCreateUser

Perfect: It is not writable nor readable. Cool!

Alrighty, let's hit the reset button on all that dummy code. Get rid of the #[Ignore]... and let's see if we have any extra use statements up here. Then, over in our processor, remove the ->dump()... and in our test, get rid of that extra field and the other ->dump(). All clean!

Avoiding Writable on the Identifier

On this topic of readable and writable, right now, we can actually change the id field in a PATCH request. Watch: set this to 47... which I just made up, and... it fails with a 500 error!

Open up the error:

Entity 47 not found.

That's coming from our state processor. It's coming from down here... it reads the id up here and tries to find that in the database... but it's not there. If we had used a valid id, it would have queried for that other User entity... then we would have updated the properties on that!. That's a big no-no. At least with how our code is written, by making id writable, we're allowing the user to change which user is being modified.

Let's look at the full flow. First, our provider found the original User entity with the id from the URL... and mapped that over to a UserApi object. Good so far. Then, during deserialization, the id on the UserApi object was changed to 47. Finally, in the state processor, we tried to query for an entity with id=47... which is ultimately what we would have saved to the database.

Over in UserApi, to fix this, above id, add writable: false.

... lines 1 - 24
class UserApi
#[ApiProperty(readable: false, writable: false, identifier: true)]
public ?int $id = null;
... lines 29 - 45

Or we could use the #[Ignore] attribute that we saw a second ago... since we don't want this to be readable or writable. The id property helps generate the IRI... but it's not really part of our API.

If we run that test now... it passes because it's ignoring the new id field in the JSON. Life is good.

While we're here, in UserApi, there are two other properties that, for now, I want to make read-only. Above $dragonTreasures, make this writable: false... though we are going to make this writable later.

... lines 1 - 24
class UserApi
... lines 27 - 42
#[ApiProperty(writable: false)]
public array $dragonTreasures = [];
... lines 45 - 47

Below, do the same for $flameThrowingDistance... because this is a fake property that we're generating as a random number.

... lines 1 - 24
class UserApi
... lines 27 - 42
#[ApiProperty(writable: false)]
public array $dragonTreasures = [];
... line 45
#[ApiProperty(writable: false)]
public int $flameThrowingDistance = 0;

Using "security" to hide/show a field

Oh, and another way to control whether a field is readable or writable is the security attribute. For example, if $flameThrowingDistance were only readable or writable if you had a certain role, you could use the security attribute to check for that. We'll see this a bit later.

Different Input/Output Classes?

Finally, I want to mention one last strategy for conditional fields... even though we won't do it. If the input JSON and output JSON for your API resource start to look really different, it is possible to have separate classes for your input and your output. You could have something like a UserApiRead and a separate UserApiWrite. The UserApiRead would be used for the read operations like GET and GET collection. And UserApiWrite would be used for PUT, PATCH, and POST operations.

Though, full disclosure: I haven't actually played with this yet. It should work, but there are probably some road bumps and details along the way. One other thing to keep in mind is that, on UserApiWrite, you could, in theory, set the output to UserApiRead. That would allow the user to send data in the format of UserApiWrite, but be returned JSON from UserApiRead. But, to make this work, after saving the UserApiWrite in your state processor, you would need to turn it into a UserApiRead and return that.

Anyway, that's definitely more advanced, but if it's interesting, and you try it, let me know!

Next up: Let's polish our new API resource by re-adding validation and security.

Leave a comment!

Login or Register to join the conversation
Andrei-V Avatar
Andrei-V Avatar Andrei-V | posted 1 month ago

Actually, trying to separate reading and writing didn't work for me, because when I add
stateOptions: new Options(entityClass: SomeClass::class),
api platform serialization listener sets force_resource_class context parameter to operation's class value and it would be dto for writing. Then I'm getting a serialization error, saying something like 'Cannot access x field on dto object' on non-intersecting fields. But if I try old-fashioned way without dtos, I cat use custom controller getting entity of one class and returning entity of another one without problems, because in this case there is no need in stateOptions

Andrei-V Avatar

Posted too soon)
Setting operation class explicitly to read dto makes it all work


Hey @Andrei-V!

I love that you tried this and reported back - I was very curious! So the solution is working well for you now? By "Setting operation class explicitly to read dto" can you mention exactly what you did? I'm curious - and it might help others.

Thank you :)

Andrei-V Avatar

For POST and PATCH operations it works now, yes. My setup is like following (I add product to order and return the entire order for convenience):

    shortName: 'OrderProduct',
    operations: [
        new Get(),
        new GetCollection(),
        new Post(
            uriTemplate: '/order_products',
            controller: AddProductToOrderController::class,
            class: OrderDto::class,
            output: OrderDto::class,
            read: false,
            name: 'order_product_add',
    order: ['position' => 'ASC'],
    provider: EntityToDtoStateProvider::class,
    stateOptions: new Options(entityClass: OrderProduct::class),
class OrderProductDto

class AddProductToOrderController extends AbstractController
   public function __invoke(OrderProductDto $data, Request $request): OrderDto
        $order = $this->orderRepository->findOneBy(['uuid' => $data->orderUuid]);
        // Adding product to order  
        $dto = $this->microMapper->map($order, OrderDto::class);
        $dto->id = $order->getId();
        return $dto;

Its also mandatory to add both output and class operation parameters

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