DTO Quirks: Embedded Objects

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

To see another, kind of, "quirk" of DTO's, go to /api/users.jsonld. Oh, this tells me to log in. Ooooook. I'll go to the homepage, hit log in, and... excellent. Close that tab and refresh again.

Check out the embedded cheeseListings field. That's... not right. An embedded object... with only the @id field?

We know that if none of the fields on a related object will be serialized, then API Platform should return an array of IRI strings instead of embedding the objects.

This is a bug in how the readableLink for properties is calculated when you have a DTO. I've actually fixed this bug... but I need to finish that pull request.

Specifically, in the User class, if we search for getPublishedCheeseListings(), this is the method that gives us the cheeseListings property:

... lines 1 - 42
class User implements UserInterface
{
... lines 45 - 210
/**
... line 212
* @SerializedName("cheeseListings")
... line 214
*/
public function getPublishedCheeseListings(): Collection
{
return $this->cheeseListings->filter(function(CheeseListing $cheeseListing) {
return $cheeseListing->getIsPublished();
});
}
... lines 222 - 288
}

But because CheeseListing uses a DTO, it doesn't calculate readableLink correctly. Remember: readableLink is calculated by checking to see if the embedded object - CheeseListing has any properties that are in the same normalization groups as User. But... since CheeseListing isn't actually the object that will ultimately be serialized... API Platform should really check to see if CheeseListingOutput has any fields in the user:read group.

Anyways, one way to fix this is just to force it. We can say @ApiProperty with readableLink=false:

... lines 1 - 42
class User implements UserInterface
{
... lines 45 - 210
/**
* @ApiProperty(readableLink=false)
... lines 213 - 215
*/
public function getPublishedCheeseListings(): Collection
{
... lines 219 - 221
}
... lines 223 - 289
}

Now, when we move over and refresh... that will force it to use IRI strings. So... this is another quirk to be aware of, but hopefully it will get fixed soon.

IRI String Problem with Multiple Output Classes

By the way, the problem of an object being embedded when it should be an IRI string gets a bit worse if you use multiple output classes. Like, if User also had a UserOutput with a cheeseListings field... even adding readableLink=false wouldn't help. If you have this situation, you can check out a conversation about it in the comments.

Re-Embedding some Fields

Anyways, I'm going to remove the readableLink. Why? Because originally, before we started with all this output stuff, we were actually embedding the CheeseListing data in User because we were including a couple of fields.

In CheeseListing, go down to the title property. We put this in the user:read group... and we did the same for price:

... lines 1 - 62
class CheeseListing
{
... lines 65 - 71
/**
... line 73
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
... lines 75 - 80
*/
private $title;
... lines 83 - 90
/**
... lines 92 - 94
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
... line 96
*/
private $price;
... lines 99 - 221
}

We did that because we wanted these two fields to be embedded when serializing a User.

The reason that wasn't happening now is... well... because I forgot to add these in CheeseListingOutput. Let's fix that: above title, add user:read and then also add user:read to price:

... lines 1 - 8
class CheeseListingOutput
{
/**
... lines 12 - 13
* @Groups({"cheese:read", "user:read"})
... line 15
*/
public $title;
... lines 18 - 24
/**
... line 26
* @Groups({"cheese:read", "user:read"})
*/
public $price;
... lines 30 - 59
}

Let's check it out! Refresh now. That is how it looked before.

Cleaning Up CheeseListing!

So... hey! We switched to an output DTO! And we're now getting the same output we had before! Yes, there were a few bumps along the way, but overall, it's a really clean process. This output class holds the fields that we actually want to serialize and the data transformer gives us a simple way to create that object from a CheeseListing:

... lines 1 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
... lines 11 - 13
public function transform($cheeseListing, string $to, array $context = [])
{
$output = new CheeseListingOutput();
$output->title = $cheeseListing->getTitle();
$output->description = $cheeseListing->getDescription();
$output->price = $cheeseListing->getPrice();
$output->owner = $cheeseListing->getOwner();
$output->createdAt = $cheeseListing->getCreatedAt();
return $output;
}
... lines 25 - 29
}

So let's celebrate! If you bring the pizza, I'll clean up the CheeseListing class. Because... it no longer needs anything related to serializing.... because this object is no longer being serialized!

Search for :read to find things we can delete. Remove cheese:read and user:read from title, but keep the write groups because we are still deserializing into this object when creating or updating cheese listings:

... lines 1 - 62
class CheeseListing
{
... lines 65 - 71
/**
... line 73
* @Groups({"cheese:write", "user:write"})
... lines 75 - 80
*/
private $title;
... lines 83 - 198
}

Then, down on description, remove @Groups entirely... for price, remove the two read groups, and also remove cheese:read above owner:

... lines 1 - 62
class CheeseListing
{
... lines 65 - 83
/**
* @ORM\Column(type="text")
* @Assert\NotBlank()
*/
private $description;
/**
... lines 91 - 93
* @Groups({"cheese:write", "user:write"})
... line 95
*/
private $price;
... lines 98 - 109
/**
... lines 111 - 112
* @Groups({"cheese:collection:post"})
... line 114
*/
private $owner;
... lines 117 - 198
}

Finally, down on getShortDescription(), we can remove the method entirely! Well, if you're calling it from somewhere else in your app, keep it. But we're not. Also delete getCreatedAtAgo():

... lines 1 - 62
class CheeseListing
{
... lines 65 - 139
/**
* @Groups("cheese:read")
*/
public function getShortDescription(): ?string
{
if (strlen($this->description) < 40) {
return $this->description;
}
return substr($this->description, 0, 40).'...';
}
... lines 151 - 188
/**
* How long ago in text that this cheese listing was added.
*
* @Groups("cheese:read")
*/
public function getCreatedAtAgo(): string
{
return Carbon::instance($this->getCreatedAt())->diffForHumans();
}
... lines 198 - 221
}

This is a nice benefit of DTO's: we can slim down our entity class and focus it on just being an entity that persists data. The serialization logic is somewhere else.

Let's make sure I didn't break something accidentally: move over, refresh the users endpoint and... bah! The cheeseListings property became an array of IRIs! This is, once again, a case where readableLink is not being calculated correctly. Now that we've removed the groups from CheeseListing, API Platform incorrectly thinks that User and CheeseListing don't have any overlapping normalization groups... but in reality, CheeseListingOutput does.

Re-add the @ApiProperty but this time say readableLink=true because we do want to force an embedded object:

... lines 1 - 42
class User implements UserInterface
{
... lines 45 - 210
/**
* @ApiProperty(readableLink=true)
... lines 213 - 215
*/
public function getPublishedCheeseListings(): Collection
{
... lines 219 - 221
}
... lines 223 - 289
}

When we refresh now... yes! It's back to an embedded object. Also try /api/cheeses.jsonld... that looks good, and let's run the tests one last time:

symfony php bin/phpunit

They do pass. With output DTO's, you need to be a bit more careful, though some - but not all - of these "quirks" have already been fixed or will be soon. The important thing to keep in mind is that DTO's are not serialized in exactly the same way as ApiResource classes. So code carefully.

Next: let's talk about using an input DTO.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.0 || ^8.0",
        "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.9.10
        "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
    }
}