Buy Access to Course
22.

Controlling Fields without Groups

|

Share this awesome video!

|

Keep on Learning!

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

When your API resource is on an entity, serialization groups are a must because you'll definitely have some properties that you want to show or not show. But serialization groups add complexity. One of the big benefits of having a separate class for your API is not needing serialization groups. Because... the whole point of your API class is to represent your API... so, in theory, you'll want every property to be part of your API.

But, in the real world, that's not always true. And we just ran into one case: password should be a write-only field. Let's try to replicate some of the complexity that our User entity originally had, but by avoiding serialization groups.

In UserResourceTest, down here, remove the ->dump()... and after we ->assertStatus(201), assert that the password property is not returned. To do that, we can say ->use(function(Json $json)). The use() function comes from browser and there are a few different objects - like Json - that you can ask it to pass you via the type-hint. In this case, browser takes the JSON from the last response, puts it into a Json object and passes it to us. Use it by saying $json->assertMissing('password').

89 lines | tests/Functional/UserResourceTest.php
// ... lines 1 - 6
use Zenstruck\Browser\Json;
// ... lines 8 - 10
class UserResourceTest extends ApiTestCase
{
// ... lines 13 - 15
public function testPostToCreateUser(): void
{
$this->browser()
// ... lines 19 - 26
->use(function (Json $json) {
$json->assertMissing('password');
})
// ... lines 30 - 36
;
}
// ... lines 39 - 87
}

If we try that now:

symfony php bin/phpunit --filter=testPostToCreateUser

It fails because password does exist.

readable: false

Okay, let's take a tour of how we can customize our API fields without groups. One of the easiest, (and, coincidentally, my favorite) is to use #[ApiProperty()] with readable: false.

46 lines | src/ApiResource/UserApi.php
// ... lines 1 - 7
use ApiPlatform\Metadata\ApiProperty;
// ... lines 9 - 24
class UserApi
{
// ... lines 27 - 35
#[ApiProperty(readable: false)]
public ?string $password = null;
// ... lines 38 - 44
}

We want this to be writable, but not readable.

symfony php bin/phpunit --filter=testPostToCreateUser

And... that fixes things! Beautiful.

Let's repeat this for id... because id is pretty useless since we have @id.

90 lines | tests/Functional/UserResourceTest.php
// ... lines 1 - 10
class UserResourceTest extends ApiTestCase
{
// ... lines 13 - 15
public function testPostToCreateUser(): void
{
// ... lines 18 - 26
->use(function (Json $json) {
$json->assertMissing('password');
$json->assertMissing('id');
})
// ... lines 31 - 38
}
// ... lines 40 - 88
}

When we run that... it fails because id is being returned. So now, copy... just the readable: false part... add #[ApiProperty] above id, paste, and I'll also add identifier: true... just to be explicit.

47 lines | src/ApiResource/UserApi.php
// ... lines 1 - 24
class UserApi
{
#[ApiProperty(readable: false, identifier: true)]
public ?int $id = null;
// ... lines 29 - 45
}

And now...

symfony php bin/phpunit --filter=testPostToCreateUser

That passes.

writable: false

Let's keep going. Copy the next test name - testPatchToUpdateUser - and run it:

symfony php bin/phpunit --filter=testPatchToUpdateUser

It passes immediately! Yay! ->patch() is already working. To dive deeper into other ways we can hide or show fields, also send a flameThrowingDistance field in the JSON set to 999. And down here, ->dump() the response.

92 lines | tests/Functional/UserResourceTest.php
// ... lines 1 - 10
class UserResourceTest extends ApiTestCase
{
// ... lines 13 - 40
public function testPatchToUpdateUser(): void
{
// ... lines 43 - 44
$this->browser()
// ... lines 46 - 53
->dump()
->assertStatus(200);
}
// ... lines 57 - 90
}

Before we try this, find EntityClassDtoStateProcessor. Right after we set the id, dump($data). Those two dumps will help us understand exactly how this all works.

70 lines | src/State/EntityClassDtoStateProcessor.php
// ... lines 1 - 15
class EntityClassDtoStateProcessor implements ProcessorInterface
{
// ... lines 18 - 27
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = [])
{
// ... lines 30 - 40
$data->id = $entity->getId();
dump($data);
// ... lines 43 - 44
}
// ... lines 46 - 68
}

Now run the test:

symfony php bin/phpunit --filter=testPatchToUpdateUser

And... awesome. The first dump on top - from the state processor - shows flameThrowingDistance 999, which means the field is writable. And below, the response returned 999, which means the field is also readable. Yup... this is a normal, boring field. If the user sends the field in JSON, that new value is deserialized onto the object.

Ok, experimentation time! In UserApi, above the property, start with the same #[ApiProperty()] and readable: false. We've already seen this.

48 lines | src/ApiResource/UserApi.php
// ... lines 1 - 24
class UserApi
{
// ... lines 27 - 44
#[ApiProperty(readable: false)]
public int $flameThrowingDistance = 0;
}

When we run the test, on top, the "999" was written onto the UserApi, but it doesn't show up in the response. It's writable, but not readable.

If we also pass writable: false... and try again. On top, the value is just "10". The field is not writable, so the field in the JSON was ignored.

48 lines | src/ApiResource/UserApi.php
// ... lines 1 - 24
class UserApi
{
// ... lines 27 - 44
#[ApiProperty(readable: false, writable: false)]
public int $flameThrowingDistance = 0;
}

It's also not in the response: it's not readable or writable.

The readable/writable options alone are probably going to solve most situations. But next, let's learn some other tricks and see why you probably want to make sure that your identifier is not writable.