Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

DTO Quirks

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

The last field that we're missing on CheeseListingOutput is owner:

... lines 1 - 62
class CheeseListing
{
... lines 65 - 110
/**
* @ORM\ManyToOne(targetEntity="App\Entity\User", inversedBy="cheeseListings")
* @ORM\JoinColumn(nullable=false)
* @Groups({"cheese:read", "cheese:collection:post"})
* @IsValidOwner()
*/
private $owner;
... lines 118 - 221
}

No worries: in CheeseListingOutput, add public $owner. Then copy the PHPDoc from price and paste that here. We know that this will be a User object and we'll put it in the cheese:read group:

... lines 1 - 4
use App\Entity\User;
... lines 6 - 8
class CheeseListingOutput
{
... lines 11 - 32
/**
* @var User
* @Groups({"cheese:read"})
*/
public $owner;
... lines 38 - 59
}

Over in the data transformer, populate that with $output->owner = $cheeseListing->getOwner():

... lines 1 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
... lines 11 - 13
public function transform($cheeseListing, string $to, array $context = [])
{
... lines 16 - 19
$output->owner = $cheeseListing->getOwner();
... lines 21 - 23
}
... lines 25 - 29
}

Easy enough! Try it: find your browser, refresh, and it works! The owner field is an embedded object because the phoneNumber field is being exposed.

It turns out, this detail is important. Go into the User class and look at the phoneNumber property. This is actually in two groups: owner:read an admin:read:

... lines 1 - 42
class User implements UserInterface
{
... lines 45 - 92
/**
... line 94
* @Groups({"admin:read", "owner:read", "user:write"})
*/
private $phoneNumber;
... lines 98 - 288
}

Right now, I'm logged in as an admin... and we created special code in the last tutorial to always add the admin:read group in this situation. This is the reason why we're able to see the phoneNumber on every user.

Let's see what happens when we are not an admin. Open a new tab, go to the homepage and click log out. Perfect.

User Serialization Has Changed?

Now that we're anonymous, refresh the same endpoint. Error? Interesting:

The return value of UserNormalizer::normalize() - that's a class we created in a previous tutorial - must be type array, string returned.

Let's go check that out: src/Serializer/Normalizer/UserNormalizer.php. The purpose of this is to add an extra owner:read group if the User that's being serialized is the currently-authenticated user:

... lines 1 - 11
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
... lines 14 - 27
public function normalize($object, $format = null, array $context = array()): array
{
$isOwner = $this->userIsOwner($object);
if ($isOwner) {
$context['groups'][] = 'owner:read';
}
$context[self::ALREADY_CALLED] = true;
return $this->normalizer->normalize($object, $format, $context);
}
... lines 39 - 65
}

The error says that this method is returning a string but it's supposed to return an array. And... it's right. Look at my normalize() method: I gave it an array return type:

... lines 1 - 11
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
... lines 14 - 27
public function normalize($object, $format = null, array $context = array()): array
{
... lines 30 - 37
}
... lines 39 - 65
}

But apparently, when we call $this->normalizer->normalize(), this returns a string:

... lines 1 - 11
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
... lines 14 - 27
public function normalize($object, $format = null, array $context = array()): array
{
... lines 30 - 36
return $this->normalizer->normalize($object, $format, $context);
}
... lines 39 - 65
}

And, hmm... that makes sense. Now that we're anonymous, the phoneNumber field will not be returned. And so, when the embedded User object is serialized, instead of returning an array of fields, it is now normalized into its IRI string.

Ok! So if you normalize a User object, sometimes it will be an object and sometimes it will be an IRI string. The fix is to remove the array return type:

... lines 1 - 11
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface
{
... lines 14 - 27
public function normalize($object, $format = null, array $context = array())
{
... lines 30 - 37
}
... lines 39 - 65
}

That was actually never needed... it's not on the normalize() method's interface. I added it simply because I thought this would always return an array.

When we refresh now... yep! The owner property is an IRI string.

But... wait a second. Why didn't we have this error before? Before we started working with the output DTO stuff... shouldn't we have had this same problem with UserNormalizer? Why wasn't it a problem until now?

DTO's Serialize Differently

Here's the answer, and it's important. When you use an output class like we're doing for CheeseListing, the object that's ultimately serialized is CheeseListingOutput:

... lines 1 - 20
/**
* @ApiResource(
* output=CheeseListingOutput::CLASS,
... lines 24 - 47
* )
... lines 49 - 61
*/
class CheeseListing
{
... lines 65 - 221
}

And because that class isn't technically an API Resource class, it's serialized in a slightly different way internally. For the serialization nerds out there, API resource classes are usually normalized using ItemNormalizer which extends AbstractItemNormalizer. But with a DTO object, it instead uses the simpler ObjectNormalizer.

Where Did @type Go?

This causes small, but important differences. For example, when you use an output DTO, the @type field is gone. We have @id... but not @type. This actually makes one of our tests fail.

Find your terminal and run:

symfony php bin/phpunit

Yep! One failure because one test is looking for @type. Let's open this test up: tests/Functional/CheeseListingResourceTest.php and then scroll down to testGetCheeseListingCollection(). Let's see... here it is:

... lines 1 - 10
class CheeseListingResourceTest extends CustomApiTestCase
{
... lines 13 - 146
public function testGetCheeseListingCollection()
{
... lines 149 - 167
$this->assertJsonContains(['hydra:member' => [
0 => [
... line 170
'@type' => 'cheese',
... lines 172 - 177
]
]]);
}
... lines 181 - 197
}

That @type is no longer being returned. For now, just delete it so that the tests will pass.

But good news! Thanks to the API Platform team, this bug has been fixed and should be released in API Platform 2.5.8. But since that hasn't been released yet at the time of this recording, we'll move on.

Run the tests again:

symfony php bin/phpunit

And... green! Next, I want to look a bit deeper at how the serialization of embedded objects is different with DTO classes and what to do about it.

Leave a comment!

4
Login or Register to join the conversation
Default user avatar
Default user avatar Geoff Maddock | posted 2 years ago

Anyone have direction on re-adding the @type? I find this useful, but I couldn't determine how to re-add this after adding a DTO.

Reply

Hey Geoff Maddock!

I'm not sure of a workaround - here is the issue tracking this on API Platform: https://github.com/api-plat...

Cheers!

Reply
Default user avatar
Default user avatar Geoff Maddock | weaverryan | posted 2 years ago

I glanced over the fact that it was fixed in an actually released version. Once I updated it worked!

Reply

Oh really? That's amazing! Woohoo!

Reply
Cat in space

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

This tutorial also works great with API Platform 2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": ">=8.1",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.10
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.12.1
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.2
        "doctrine/orm": "^2.4.5", // 2.8.2
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0 || ^5.0", // 5.2.2
        "ramsey/uuid-doctrine": "^1.6", // 1.6.0
        "symfony/asset": "5.1.*", // v5.1.5
        "symfony/console": "5.1.*", // v5.1.5
        "symfony/debug-bundle": "5.1.*", // v5.1.5
        "symfony/dotenv": "5.1.*", // v5.1.5
        "symfony/expression-language": "5.1.*", // v5.1.5
        "symfony/flex": "^1.1", // v1.18.7
        "symfony/framework-bundle": "5.1.*", // v5.1.5
        "symfony/http-client": "5.1.*", // v5.1.5
        "symfony/monolog-bundle": "^3.4", // v3.5.0
        "symfony/security-bundle": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/validator": "5.1.*", // v5.1.5
        "symfony/webpack-encore-bundle": "^1.6", // v1.8.0
        "symfony/yaml": "5.1.*" // v5.1.5
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.3.2
        "symfony/browser-kit": "5.1.*", // v5.1.5
        "symfony/css-selector": "5.1.*", // v5.1.5
        "symfony/maker-bundle": "^1.11", // v1.23.0
        "symfony/phpunit-bridge": "5.1.*", // v5.1.5
        "symfony/stopwatch": "5.1.*", // v5.1.5
        "symfony/twig-bundle": "5.1.*", // v5.1.5
        "symfony/web-profiler-bundle": "5.1.*", // v5.1.5
        "zenstruck/foundry": "^1.1" // v1.8.0
    }
}
userVoice