Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Output Properties & Metadata

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 go see how having an output class affects our documentation. Refresh the docs homepage. One of the things that this tells us is what to expect back when you use an endpoint. For example, if we look at the get item operation for cheeses, we know that, thanks to our "work-in-progress" output class, this will return something different than it did a few minutes ago. And if we look at the schema... yeah, awesome! The documentation recognizes this! It correctly tells us that the only field we should expect back is title.

DTO Documentation Models

Oh, and notice that it has this really weird name. This is referring to the "models" at the bottom of the page. Scroll down to see them.

API Platform creates a unique "model" for each different way that a resource might be returned based on your serialization groups. And when you create an output DTO, it creates yet another model class to describe that... with a unique "sha" in the name.

I don't know the full story behind why that sha is there, but technically, you can configure a different output class for a resource on an operation-by-operation basis. So basically, API Platform uses a hash to guarantee that each output model has a unique name. It's a little ugly, but I don't think it really makes much of a difference.

The big point is: API Platform does correctly notices that we're using an output class and is uses that class to generate which fields will be returned in the documentation, which, right now, is only title.

Documenting the Fields

But... it doesn't have any documentation for that field. Like, it doesn't know what type title will be.

And... that's no surprise! When we serialize a CheeseListing entity, API Platform can use the Doctrine metadata above the title property to figure out that it's a string:

... lines 1 - 62
class CheeseListing
{
... lines 65 - 71
/**
* @ORM\Column(type="string", length=255)
... lines 74 - 80
*/
private $title;
... lines 83 - 221
}

But in this case, when it looks at title, it doesn't get any info about it:

... lines 1 - 6
class CheeseListingOutput
{
/**
* @Groups({"cheese:read"})
*/
public $title;
}

No problem! We just need to add that info ourselves. One way is by using PHP 7.4 property types. For example, I can say public string $title:

... lines 1 - 6
class CheeseListingOutput
{
... lines 9 - 11
public string $title;
}

Now, my editor thinks this is invalid because it thinks I'm using PHP 7.3... but I'm actually using 7.4. So this will work. But if you're not using 7.4, you can always use @var instead.

DTO Metadata Cache Bug

Ok, refresh the docs now.... look at the same get item operation, go down to schema and... oh! It did not work. The docs still don't know the type for title!

We've just experienced our first "quirk" of the DTO system. Normally, if we modify something on CheeseListing, API Platform realizes that it needs to rebuild the property metadata cache. But there's a bug in that logic when using an input or output class.

Tip

You can track this issue here: https://github.com/api-platform/core/issues/3695

It's not a big deal once you know about it: we can trigger a rebuild manually by changing something inside of CheeseListing. I'll hit save, move over, and refresh. Notice the reload takes a bit longer this time because the cache is rebuilding. Check out the endpoint, go to the schema and... yes! It knows title is a string!

Back in the class, I'm going to remove the PHP 7.4 type and use @var instead, just so that everyone can code along with me. Let's also add a description:

The title of this listing

... lines 1 - 6
class CheeseListingOutput
{
/**
* The title of this listing
*
... line 12
* @var string
*/
public $title;
}

That will also be used in the docs.

Adding More Fields

Ok, let's add the rest of the fields we need to this class. Check out CheeseListing: it looks like description is usually serialized and so is price:

... lines 1 - 62
class CheeseListing
{
... lines 65 - 83
/**
* @ORM\Column(type="text")
* @Groups({"cheese:read"})
* @Assert\NotBlank()
*/
private $description;
/**
* The price of this delicious cheese, in cents
*
* @ORM\Column(type="integer")
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
* @Assert\NotBlank()
*/
private $price;
... lines 99 - 221
}

Copy the title property, paste, rename it to description... and remove the docs. Copy this and make one more property called price, which is an int:

... lines 1 - 6
class CheeseListingOutput
{
... lines 9 - 16
/**
* @var string
* @Groups({"cheese:read"})
*/
public $description;
/**
* @var integer
* @Groups({"cheese:read"})
*/
public $price;
}

Now that we've added these properties, we need to go into our data transformer and set them. So, $output->description = $cheeseListing->getDescription() and $output->price = $cheeseListing->getPrice():

... lines 1 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
... lines 11 - 13
public function transform($cheeseListing, string $to, array $context = [])
{
... line 16
$output->title = $cheeseListing->getTitle();
$output->description = $cheeseListing->getDescription();
$output->price = $cheeseListing->getPrice();
... lines 20 - 21
}
... lines 23 - 27
}

These data transformer classes are delightfully boring.

Before we try this, let's grab a couple other fields from CheeseListing. Search for cheese:read. But ignore owner for now: we'll come back to that in a minute:

... lines 1 - 62
class CheeseListing
{
... lines 65 - 71
/**
... line 73
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
... lines 75 - 80
*/
private $title;
/**
... line 85
* @Groups({"cheese:read"})
... line 87
*/
private $description;
/**
... lines 92 - 94
* @Groups({"cheese:read", "cheese:write", "user:read", "user:write"})
... line 96
*/
private $price;
... lines 99 - 139
/**
* @Groups("cheese:read")
*/
public function getShortDescription(): ?string
{
... lines 145 - 149
}
... lines 151 - 188
/**
... lines 190 - 191
* @Groups("cheese:read")
*/
public function getCreatedAtAgo(): string
{
... line 196
}
... lines 198 - 221
}

Ok: we also output a shortDescription field via this getShortDescription() method. Copy that whole thing and, in CheeseListingOutput paste it at the bottom:

... lines 1 - 6
class CheeseListingOutput
{
... lines 9 - 28
/**
* @Groups("cheese:read")
*/
public function getShortDescription(): ?string
{
if (strlen($this->description) < 40) {
return $this->description;
}
return substr($this->description, 0, 40).'...';
}
}

That will work exactly like before: it's referencing the description property and it has the group on it.

Back in CheeseListing, if you search again, there is one more field to move: createdAtAgo. Copy this method... then paste at the bottom. PhpStorm politely asks me if I want to import the Carbon use statement. I do!

... lines 1 - 4
use Carbon\Carbon;
... lines 6 - 7
class CheeseListingOutput
{
... lines 10 - 41
/**
* How long ago in text that this cheese listing was added.
*
* @Groups("cheese:read")
*/
public function getCreatedAtAgo(): string
{
return Carbon::instance($this->getCreatedAt())->diffForHumans();
}
}

But, hmm: this method references a $createdAt property... which we do not have inside this class. We need to add it. Add a public $createdAt, but I'm not going to put any groups above this because this isn't a field that we will expose in our API directly. We just need its data:

... lines 1 - 7
class CheeseListingOutput
{
... lines 10 - 29
public $createdAt;
... lines 31 - 52
}

Oh, and, by the way, we could simplify this by, instead, creating a $createdAtAgo property, exposing that, then setting the string onto that property from our data transformer. I won't do that now, but... it's a pretty great idea and shows off the power of data transformers: you can do the work there and then have super simple DTO classes.

Anyways, back in the data transformer, set this property: $output->createdAt = $cheeseListing->getCreatedAt():

... lines 1 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
... lines 11 - 13
public function transform($cheeseListing, string $to, array $context = [])
{
... lines 16 - 18
$output->price = $cheeseListing->getPrice();
$output->createdAt = $cheeseListing->getCreatedAt();
... lines 21 - 22
}
... lines 24 - 28
}

I think we're ready! Let's first refresh the documentation: open the item operation, go to schema and... yes! It did rebuild the cache that time and we can see all our custom fields and their types.

And if we go over and refresh the actual endpoint... that works to! How awesome is that? We have 5 fields and you can quickly look at our output class to know what they will be.

Next: the one field that we aren't exposing yet is the owner field. Let's add that. Though, when we do, there's going to be a slight problem.

Leave a comment!

7
Login or Register to join the conversation
Petru L. Avatar
Petru L. Avatar Petru L. | posted 2 years ago

Hey Ryan, i was wondering if there's a way to only add the fields that needs to be processed differently, in the transformer. For example, i have a File entity with the usual fields: name, extension, mimetype, etc ( those are also db columns ) and beside those i also have a file property that isn't a db column. This file is actually a resource that is sent via api in a multipart format ( or maybe just a link to the resource ) and then i process it.( i save it in s3, then save only the s3 path in my db ). Can i force api platform to use the normal fields on read/write from the entity and use only the file property from the FileInput ( and FileInputDataTransformer), since the only time i need the file field is when i save it to s3 and save its properties to db

Reply
Petru L. Avatar
Petru L. Avatar Petru L. | Petru L. | posted 2 years ago | edited

Quick mention: i've defined groups for serialization/deserialization in my entity and due to that fact, if i leave them in my entity without adding them in the Dto class, it triggers an error: Can't get a way to read the property \"extension\" in class \"App\\Data\\File Output\"

Reply

Hey Petru L.!

Hmm. What if you *don't* create an input DTO at all. Instead, add the non-persisted "file" property to your entity (it sounds like you already have this) and use a custom data persister to handle processing the resource/link on that "file" property (the saving it to s3 part).

Would that work? Or am I missing a complication? With just one "weird" field, this is the first idea that popped into my head. Input/output DTO's are more of a headache, unless you have a lot of differences between your input/output and how your entity looks.

Cheers!

Reply
Petru L. Avatar

Yeah, it looks like data persister might be the way to go but while this solves my POST problem, i got a similar issue with the GET. For example, after a certain file has been saved into db ( and in s3 ), one user may request that file, and the path field in my db contain only the relative path to the file, i need to transform it into the absolute path and not just that, maybe the user wants a thumbnail of it, and so i'll have to serve the liip-imagine cached thumbnail path, or maybe he request the image of a certain resolution, and i'll have to do some more parsing there too. Should i stick with the DTO for the output or is something better for this case? Thank you

Reply

Hey Petru L.!

> i got a similar issue with the GET. For example, after a certain file has been saved into db ( and in s3 ), one user may request that file, and the path field in my db contain only the relative path to the file, i need to transform it into the absolute path

No problem :). Add one (or some) non-persisted fields and use a custom data provider to supply those :). We talk about this a bit earlier in the tutorial - https://symfonycasts.com/sc...

Cheers!

Reply
Petru L. Avatar
Petru L. Avatar Petru L. | Petru L. | posted 2 years ago | edited

Petru L. , came across another problem... I'm trying to send a multipart request through api and so i enabled the multipart type in api_platform.yaml, then it said it couldn't deserialized and so i turn the deserialization off in the entity and then i got stuck at:

Could not resolve argument $data of "api_platform.action.post_collection()", maybe you forgot to register the controller as a service or missed tagging it with the "controller.service_arguments"?

. In the api platform guide to upload file, they create a custom action for it: https://api-platform.com/do.... So maybe that's the only way to do it??

Reply

Hey Petru L.!

I've never used API Platform's native file upload tools. But yes, my guess is that the custom action is needed... when you turn deserialization off, then there is no "$data" on the request attributes... and I think the normal, core controller fails. I'm doing a bit of guessing on this, but it would make sense to me :).

Cheers!

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