The Serializer
The key behind how API platform turns our objects into JSON... and also how it transforms JSON back into objects is Symfony's Serializer. symfony/serializer is a standalone component that you can use outside of API platform and it's awesome. You give it any input - like an object or something else - and it transform that into any format, like JSON, XML or CSV.
The Internals of the Serializer
As you can see in this fancy diagram, it goes through two steps. First, it takes your data and normalizes it into an array. Second, it encodes that into the final format. It can also do the same thing in reverse. If we're starting with JSON, like we're sending JSON to our API, it first decodes it to an array and then denormalizes it back into an object.
For all of this to happen, internally, there are many different normalizer objects that know how to work with different data. For example, there's a DateTimeNormalizer that's really great at handling DateTime objects. Check it out: our entity has a createdAt field, which is a DateTime object:
| // ... lines 1 - 26 | |
| class DragonTreasure | |
| { | |
| // ... lines 29 - 48 | |
| #[ORM\Column] | |
| private ?\DateTimeImmutable $plunderedAt = null; | |
| // ... lines 51 - 130 | |
| } |
If you look at our API, when we try the GET endpoint, this is returned as a special date time string. The DateTimeNormalizer is responsible for doing that.
Figuring out Which Fields to Serialize
There's also another really important normalizer called the ObjectNormalizer. Its job is to read properties off of an object so that those properties can be normalized. To do that, it uses another component called property-access. That component is smart.
For example, looking at our API, when we make a GET request to the collection endpoint, one of the fields it returns is name. But if we look at the class, name is a private property:
| // ... lines 1 - 26 | |
| class DragonTreasure | |
| { | |
| // ... lines 29 - 33 | |
| #[ORM\Column(length: 255)] | |
| private ?string $name = null; | |
| // ... lines 36 - 130 | |
| } |
So how the heck is it reading that?
That's where the PropertyAccess component comes in. It first looks to see if the name property is public. And if it's not, it then looks for a getName() method:
| // ... lines 1 - 26 | |
| class DragonTreasure | |
| { | |
| // ... lines 29 - 33 | |
| #[ORM\Column(length: 255)] | |
| private ?string $name = null; | |
| // ... lines 36 - 59 | |
| public function getName(): ?string | |
| { | |
| return $this->name; | |
| } | |
| // ... lines 64 - 130 | |
| } |
So that is what's actually called when building the JSON.
The same thing happens when we send JSON, like to create or update a DragonTreasure. PropertyAccess looks at each field in the JSON and, if that field is settable, like via a setName() method, it sets it:
| // ... lines 1 - 26 | |
| class DragonTreasure | |
| { | |
| // ... lines 29 - 33 | |
| #[ORM\Column(length: 255)] | |
| private ?string $name = null; | |
| // ... lines 36 - 59 | |
| public function getName(): ?string | |
| { | |
| return $this->name; | |
| } | |
| public function setName(string $name): self | |
| { | |
| $this->name = $name; | |
| return $this; | |
| } | |
| // ... lines 71 - 130 | |
| } |
And, it's even a bit cooler than that: it will even look for getter or setter methods that don't correspond to any real property! You can use this to create "extra" fields in your API that don't exist as properties in your class.
Adding a Virtual "textDescription" Field
Let's try that! Pretend that, when we're creating or editing a treasure, instead of sending a description field, we want to be able to send a textDescription field that contains plaintext... but with line breaks. Then, in our code, we'll transform those lines breaks into HTML <br> tags.
Let me show you what I mean. Copy the setDescription() method. Then, below, paste and call this new method setTextDescription(). It's basically going to set the description property... but call nl2br() on it first. That function literally transforms new lines into <br> tags. If you've been around as long as I have, you remember when nl2br was super cool:
| // ... lines 1 - 26 | |
| class DragonTreasure | |
| { | |
| // ... lines 29 - 83 | |
| public function setTextDescription(string $description): self | |
| { | |
| $this->description = nl2br($description); | |
| return $this; | |
| } | |
| // ... lines 90 - 137 | |
| } |
Anyways, with just that change, refresh the documentation and open the POST or PUT endpoints. Woh! We have a new field called textDescription! Yup! The serializer saw the setTextDescription() method and determined that textDescription is a "settable" virtual property!
However, we don't see this on the GET endpoint. And that's perfect! There is no getTextDescription() method, so there will not be a new field here. The new field is writable, but not readable.
Let's take this endpoint for a spin! First... I need to execute the GET collection endpoint so I can see what ids we have in the database. Perfect: I have a Treasure with ID 1. Close this up. Let's try the PUT endpoint to do our first update. When you use the PUT endpoint, you don't need send every field: only the fields you want to change.
Tip
If you're starting a new API Platform project, this PUT request will fail!
You can try a PATCH request instead. This relates to a new standard_put
config in config/packages/api_platform.yaml, which we talk about a bit
later in the tutorial.
Pass textDescription... and I'll include \n to represent some new lines in JSON.
When we try it, yes! 200 status code. And check it out: the description field has those <br> tags!
Removing Fields
Ok, so now that we have setTextDescription()... maybe that's the only way that we want to allow that field to be set. To enforce that, eradicate the setDescription() method.
Now when we refresh... and look at the PUT endpoint, we still have textDescription, but the description field is gone! The serializer realizes that it's no longer settable and removed it from our API. It would still be returned because it's something that we can read, but it's no longer writeable.
This is all really awesome. We simply worry about writing our class the way we want then API Platform builds our API accordingly.
Making the plunderedAt Field Readonly
Ok, what else? Well, it is a little weird that we can set the createdAt field: that's usually set internally and automatically. Let's fix that.
Oh, but, ya know what? I meant to call this field plunderedAt. I'll refactor and rename that property... then let PhpStorm also rename my getter and setter methods.
Cool! This will also cause the column in my database to change... so spin over to your console and run:
symfony console make:migration
I'll live dangerously and run that immediately:
symfony console doctrine:migrations:migrate
Done! Thanks to that rename... over in the API, excellent: the field is now plunderedAt.
Ok, so forget about the API for a moment: let's just do a little cleanup. The purpose of this plunderedAt field is for it to be set automatically whenever we create a new DragonTreasure.
To do that, create a public function __construct() and, inside, say this->plunderedAt = new DateTimeImmutable(). And now we don't need the = null on the property.
| // ... lines 1 - 26 | |
| class DragonTreasure | |
| { | |
| // ... lines 29 - 48 | |
| #[ORM\Column] | |
| private \DateTimeImmutable $plunderedAt; | |
| // ... lines 51 - 54 | |
| public function __construct() | |
| { | |
| $this->plunderedAt = new \DateTimeImmutable(); | |
| } | |
| // ... lines 59 - 128 | |
| } |
And if we search for setPlunderedAt, we don't really need that method anymore! Remove it!
This now means that the plunderedAt property is readable but not writeable. So, no shocker, when we refresh and open up the PUT or POST endpoint, plunderedAt is absent. But if we look at what the model would look like if we fetched a treasure, plunderedAt is still there.
Adding a Fake "Date Ago" Field
All right, one more goal! Let's add a virtual field called plunderedAtAgo that returns a human-readable version of the date, like "two months ago". To do this, we need to install a new package:
composer require nesbot/carbon
Once this finishes... find the getPlunderedAt() method, copy it, paste below, it will return a string and call it getPlunderedAtAgo(). Inside, return Carbon::instance($this->getPlunderedAt())) then ->diffForHumans().
| // ... lines 1 - 11 | |
| use Carbon\Carbon; | |
| // ... lines 13 - 27 | |
| class DragonTreasure | |
| { | |
| // ... lines 30 - 118 | |
| /** | |
| * A human-readable representation of when this treasure was plundered. | |
| */ | |
| public function getPlunderedAtAgo(): string | |
| { | |
| return Carbon::instance($this->plunderedAt)->diffForHumans(); | |
| } | |
| // ... lines 126 - 137 | |
| } |
So, as we now understand, there is no plunderedAtAgo property... but the serializer should see this as readable via its getter and expose it. Oh, and while I'm here, I'll add a little documentation above to describe the field's meaning.
Ok, let's try this. As soon as we refresh and open a GET endpoint, we see the new field under the example! We can also see the fields we'll receive down in the Schemas section. Back up, let's try the GET endpoint with ID one. And... how cool is that?
Next: what if we do want to have certain getter or setter methods in our class, like setDescription(), but we do not want that to be part of our API? The answer: serialization groups.
11 Comments
Since I am upgrading an app from 2.6 to 3 right now, I was wondering about a behavior of the serializer that I ovserved.
In API-Platform >= 3, the default setting in the api_platform.yaml is:
So far, so good. Properties, which are null, are no longer part of the response.
But in that specific app I work a lot with Doctrine Embeddables because I have giant forms, and it is a great way to put fields into logical blocks.
But when all properties of an embeddable are null, the endpoint returns an empty array.
I expected the embeddable not to be returned at all, or to be an empty object, but an empty array is returned.
Is that really the desired behavior?
I am right now wondering how to deal with that, since I have dozens of embeddable in use in that app.
I could modify all the getters and manually loop though all the properties and return null, if they are all null, or create some kind of custom normalizer.
Furthermore, I have two questions:
Is that really the wanted behavior?
What is the best workaround?
Hey @TristanoMilano!
Ooooh, upgrading! :)
I... think so? So if you're fetching
/api/invoice/5and it has an embeddableaddressproperty... and every field in thatAddressobject is null, then it does make sense to me that the response WOULD contain anaddressproperty (sinceInvoice.addressdoes contain anAddressobject)... but then each property inside that "array" would be missing, since each property is null. But, this is just my opinion: it makes sense to me, but this is the first time I've thought about it!I don't know if this helps you, but you are free to change
skip_null_valuestofalse. They changed it totruebecause that better fits the JSON-LD spec, iirc. So, feel free to change this to false. Though, thenaddresswould still be an array/object... withnullproperties. So I'm not sure if that is better for your or not :p.Otherwise, the only solution I can think of is some sort of custom normalizer. That normalize, I think, could "support" the
Addressobject (or any other embeddable), check to see if every property isnull, and if it is, returnnull. Else, call the core normalizer system so it can do its normal work. That... doesn't seem unreasonable to me, but custom normalizers can get complex.Let me know what you end up doing and how it goes!
Cheers!
When I'm trying to execute PUT method I get following error:
Also my getters where generated with return type
staticnotselflike on video, Did I do something wrong?@Damian same problem for me
It seems that it is due to the new behavior of the PUT method which is now conform to the RFC 9110.
It does create or replace (all the data of ressource) with the new ones. The partial replace have now to be done with the PATCH method.
You can change this behavior in the config file (api_platform.yaml) : change the
standard_puttofalseYup! We will talk about this later in the tutorial. But if you're starting with a new project, a recent recipe change included the
standard_put: trueinconfig/packages/api_platform.yaml, which is why you see different behavior than I have in the tutorial up to this point.I'll add a note to help people with this.
Thanks!
Heya, for some reason visual studio code marks "Carbon" as an error in the getPlunderedAtAgo() method that its used here.
Hey @TronZin
That's unexpected. Could you double-check if that method still exists in the Carbon library? You might have got a newer version and they renamed the method. If that's not the case, your IDE may not have indexed the library correctly
Hi,
when I installed nesbot/carbon then browser shows an error:
Attempted to load class "Locale" from the global namespace.<br />Did you forget a "use" statement for "Symfony\Component\Validator\Constraints\Locale"?Adding use statment does not helps :( any ideas?
Hey Szymon,
I believe you haven't installed the
php-intlmodule in your local environment. If you're on Ubuntu, you can do thissudo apt-get install php-intlCheers!
I had installed intl extension but it required to uncomment lines in several places in php/php.ini (I have Windows 10)
I see... well, as far as I know Windows is not very friendly for developing purposes. That's why I use Windows WSL, it's not perfect but it gets the job done :)
"Houston: no signs of life"
Start the conversation!