Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine
This tutorial has a new version, check it out!

The Serializer

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

Google for Symfony serializer and find a page called The Serializer Component.

API Platform is built on top of the Symfony components. And the entire process of how it turns our CheeseListing object into JSON... and JSON back into a CheeseListing object, is done by Symfony's Serializer! If we understand how it works, we're in business!

And, at least on the surface, it's beautifully simple. Check out the diagram that shows how it works. Going from an object to JSON is called serialization, and from JSON back into an object called deserialization. To do that, internally, it goes through a process called normalizing: it first takes your object and turns it into an array. And then it's encoded into JSON or whatever format.

How Objects are Turned into Raw Data

There are actually a bunch of different "normalizer" classes that help with this job - like one that's really good at converting DateTime objects to a string and back. But the main class - the one at the heart of this process - is called the ObjectNormalizer. Behind the scenes, it uses another Symfony component called PropertyAccess, which has one superpower: if you give it a property name, like title, it's really good at finding and using getter and setter methods to access that property.

In other words, when API platform tries to "normalize" an object into an array, it uses the getter and setter methods to do that!

For example, it sees that there's a getId() method, and so, it turns that into an id key on the array... and eventually in the JSON. It does the same thing for getTitle() - that becomes title. It's just that simple!

When we send data, it does the same thing! Because we have a setTitle() method, we can send JSON with a title key. The normalizer will take the value we're sending, call setTitle() and pass it!

It's a simple, but neat way to allow your API clients to interact with your object, your API resource, using its getter and setter methods. By the way, the PropertyAccess component also supports public properties, hassers, issers, adders, removers - basically a bunch of common method naming conventions in addition to getters and setters.

Adding a Custom "Field"

Anyways, now that we know how this works, we're super dangerous! Seriously! Right now, we're able to send a description field. Let's pretend that this property can contain HTML in the database. But most of our users don't really understand HTML and, instead, just type into a box with line breaks. Let's create a new, custom field called textDescription. If an API client sends a textDescription field, we'll convert the new lines into HTML breaks before saving it on the description property.

How can we create a totally new, custom input field for our resource? Find setDescription(), duplicate it, and name it setTextDescription(). Inside, say, $this->description = nl2br($description);. It's a silly example, but even forgetting about API Platform, this is good, boring, object-oriented coding: we've added a way to set the description if you want new lines to be converted to line breaks.

... lines 1 - 18
class CheeseListing
{
... lines 21 - 83
public function setTextDescription(string $description): self
{
$this->description = nl2br($description);
return $this;
}
... lines 90 - 125
}

But now, refresh, and open up the POST operation again. Woh! It says that we can still send a description field, but we can also pass textDescription! But if your try the GET operation... we still only get back description.

That makes sense! We added a setter method - which makes it possible to send this field - but we did not add a getter method. You can also see the new field described down in the models section.

Removing "description" as Input

But, we probably don't want to allow the user to send both description and textDescription. I mean, you could, but it's a little weird - if the client sent both, they would bump into each other and the last key would win because its setter method would be called last. So, let's remove setDescription().

Refresh now. I love it! To create or update a cheese listing, the client will send textDescription. But when they fetch the data, they'll always get back description. In fact, let's try it... with id 1. Open the PUT operation and set textDescription to something with a few line breaks. I only want to update this one field, so we can just remove the other fields. And... execute! 200 status code and... a description field with some line breaks!

By the way, the fact that our input fields don't match our output fields is totally ok. Consistency is super nice - and I'll show you soon how we can fix this inconsistency. But there's no rule that says your input data needs to match your output data.

Removing createdAt Input

Ok, what else can we do? Well, having a createdAt field on the output is great, but it probably doesn't make sense to allow the client to send this: the server should set it automatically.

No problem! Don't want the createdAt field to be allowed in the input? Find the setCreatedAt() method and remove it. To auto-set it, it's back to good, old-fashioned object-oriented programming. Add public function __construct() and, inside, $this->createdAt = new \DateTimeImmutable().

... lines 1 - 54
public function __construct()
{
$this->createdAt = new \DateTimeImmutable();
}
... lines 59 - 118

Go refresh the docs. Yep, it's gone here... but when we try the GET operation, it is still in the output.

Adding a Custom Date Field

We're on a roll! So let's customize one more thing! Let's say that, in addition to the createdAt field - which is in this ugly, but standard format - we also want to return the date as a string - something like 5 minutes ago or 1 month ago.

To help us do that, find your terminal and run:

composer require nesbot/carbon

This is a handy DateTime utility that can easily give us that string. Oh, while this is installing, I'll go back to the top of my entity and remove the custom path on the get operation. That's a cool example... but let's not make our API weird for no reason.

... lines 1 - 7
/**
* @ApiResource(
... line 10
* itemOperations={
* "get"={},
... line 13
* },
... line 15
* )
... line 17
*/
... lines 19 - 118

Yep, that looks better.

Back at the terminal.... done! In CheeseListing, find getCreatedAt(), go below it, and add public function getCreatedAtAgo() with a string return type. Then, return Carbon::instance($this->getCreatedAt())->diffForHumans().

... lines 1 - 106
public function getCreatedAtAgo(): string
{
return Carbon::instance($this->getCreatedAt())->diffForHumans();
}
... lines 111 - 124

You know the drill: just by adding a getter, when we refresh... and look at the model, we have a new createdAtAgo - readonly field! And, by the way, it also knows that description is readonly because it has no setter.

Scroll up and try the GET collection operation. And... cool: createdAt and createdAtAgo.

As nice as it is to control things by simply tweaking your getter and setter methods, it's not ideal. For example, to prevent an API client from setting the createdAt field, we had to remove the setCreatedAt() method. But, what if, somewhere in my app - like a command that imports legacy cheese listings - we do need to manually set the createdAt date? Let's learn how to control this with serialization groups.

Leave a comment!

23
Login or Register to join the conversation
Will T. Avatar
Will T. Avatar Will T. | posted 1 year ago

I'm trying to update a record from an ajax call to a put API-Platform endpoint, but I'm getting a message: "Deserialization for the format \"html\" is not supported." Can anyone help me with this?

Reply

Hey Will T.!

Sorry for the slow reply! Hmm, try setting a "Content-Type" header to "application/json" in your Ajax call. I'm thinking that you're sending JSON, but because the Ajax request doesn't have a Content-Type, Api Platform thinks it's HTML, and then can't deserializer it.

Let me know if that helps!

Cheers!

Reply
Andrew M. Avatar
Andrew M. Avatar Andrew M. | posted 1 year ago | edited

The way $this->createdAt = new \DateTimeImmutable(); works out okay feels like a bit of a mystery.

Reply

Hey Andy,

Why does it feel like a mystery for you? Why do you think it should not work?

Cheers!

Reply
Nenad M. Avatar
Nenad M. Avatar Nenad M. | posted 3 years ago

Hello, are you touching the topic of custom business logic (not related to the entities) at some point of the course?
I'm still wondering should I use API Platforms for those or not?
For instance, I should make business calculations not related to the database at all - based on input API should return some value - but I would like to keep swagger documentation for API calls.
Is that even possible?
Thanks

Reply

Hi Nenad M.!

It's not something that we touch on in this tutorial - we have that topic planned for a future (but not date yet) 3rd part to this series :). For these business calculation stuff - the "perfect" solution is to create a DTO class and map *that* as your ApiResource. This would require:

A) To live in the Entity/ directory or (if in a different directory) for you to update your api_platform.yaml config file to also point to this directory
B) A custom data persister and custom data provider (to save and load data). Actually, if you aren't *writing* any data (no PUT, POST or DELETE operations) then you don't even need the data persister. For the data "provider", that would be where you do whatever calculations you need and then populate that data on the DTO.

I know that's a bit of a vague answer, but let me know if it makes sense :).

Cheers!

Reply
Paul Avatar
Paul Avatar Paul | posted 3 years ago | edited

You are creating a field createdAtAgo. But this will only work when being in the same timezone I guess or am I wrong? Also when it's longer than 24 hours ago it probably won't make a big difference anymore.

Reply

Hey Paul!

Actually, this is one of the great things about "ago" fields: they're always correct, regardless of timezone. Suppose something were created right now at 2020-06-24 02:31:00 UTC. That date will get stored in the database. 3 hours from now (so, 2020-06-24 05:31:00), that date will be loaded from the database. Then, the server (your PHP code) will compare the current data in UTC to the data from the database and ultimately return something like "3 hours ago" in your API. So, it works perfectly :).

When this does not work perfectly is if you tried to do this in JavaScript - e.g. you return 2020-06-24 02:31:00 from your server, and then convert that to an "ago" time in JavaScript. In that case, if you're not careful, you might compare the date using the user's timezone on their computer. But with this pure server-side approach, you're safe.

Cheers!

1 Reply
Paul Avatar

Oh yes that is true. I didn't think about that thanks 🙈.
And Carbon probably can also handle the language so you would pass the client language in a header field?

But how would you handle the refreshing then in the client. Let's say the site stays open for a few minutes. Then the displayed data would be incorrect.

Reply

Hey Hanswer01,

If you want to translate into a different language - yes, you can pass the locale to the date, see locale() method in docs.

> But how would you handle the refreshing then in the client. Let's say the site stays open for a few minutes. Then the displayed data would be incorrect.

Yes, that's the downside. That obviously will show fresh data only on the page load. So, users will need to refresh the page to see the difference, or if it's a problem for you - you would need to go with a more complex solution. I think JS would help the best here, i.e. you would need to change that "ago" string with JS constantly.

Cheers!

1 Reply
Paul Avatar

Thanks a lot for the detailed explanation.

Yes but then again if I have a multilingual site I have to care about a lot of things when increasing. But it's probably not that big of a deal to just show the correct ago time on load.

Reply

Yeah, multilingual website complicates things. I think so too... but depends on your project of course. Also, you can look at some websites around to see how they solve this problem. For example, GitHub shows dates in "ago" format, and looks like they use JS to update that date without page refreshing.

Cheers!

1 Reply
CDesign Avatar
CDesign Avatar CDesign | posted 4 years ago

With Symfony 4.3.1 I still get this error when loading the `/api` page:
```
An exception has been thrown during the rendering of a template ("Property "textDescription" does not exist in class "App\Entity\CheeseListing"
```
However, if I change the name of the property to 'textDescription' (rather than 'description'), it works.

Reply

Hey John christensen

It's a Symfony bug. Read this thread: https://symfonycasts.com/sc...

Cheers!

Reply
CDesign Avatar

Sry.. I did not see the 'Show more replies' link :( so it looked like the issue was still open. thx!

Reply

You are welcome!

Reply

symfony 4.3.0 throws following error when running adding setTextDescription() method:
`
composer require nesbot/carbon
...
In FileLoader.php line 166:
Property "textDescription" does not exist in class "App\Entity\CheeseListin
!! g" in . (which is being imported from "/Volumes/SanDisk/Documents/code/symf
!! ony/api-platform/config/routes/api_platform.yaml"). Make sure there is a lo
!! ader supporting the "api_platform" type.

In PropertyMetadata.php line 40:
Property "textDescription" does not exist in class "App\Entity\CheeseListing"
`

Reply
Ajie62 Avatar

I was wondering if I was the only one having this issue... I've got the same issue here. How can we fix that, please? [Update] When I tried the part with getCreatedAt, I was surprise to see that it works. So now I'm just wondering why it works with this and not with textDescription...

Reply

Hey guys,

The new version of Symfony is came out - v4.3.1. Could you please upgrade to it and say if it fixes the problem?

Cheers!

Reply
Ajie62 Avatar

Went from v4.3.0 to 4.3.1 and I still have the same issue...

Reply

Hey Ajie62 and @Azeem!

So here's what's going on :). In Symfony 4.3, a new feature called "auto validation" is added (well, you only get it if you start a new 4.3 project - the new recipe enables it: https://github.com/symfony/...

This has a bug :). It's more or less this issue: https://github.com/symfony/...

For now, you'll need to disable the auto validation feature (just comment out those two lines in validator.yaml). I'm going to push that issue forward so we can get it fixed!

Cheers!

1 Reply
Ajie62 Avatar

Hi Ryan,

Thanks for answering! I've commented out the two lines you mentioned in validator.yaml and now it's working properly. Hopefully the issue isn't too hard and will be fixed soon. :) Have a very good day!

Reply

Thanks for the report! I hadn't hit this yet, so you've helped us fix it faster :). Here is the PR that, hopefully, is the correct solution: https://github.com/symfony/...

Cheers!

Reply
Cat in space

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

This tutorial works great for Symfony 5 and API Platform 2.5/2.6.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.4.3
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.10.2
        "doctrine/doctrine-bundle": "^1.6", // 1.11.2
        "doctrine/doctrine-migrations-bundle": "^2.0", // v2.0.0
        "doctrine/orm": "^2.4.5", // v2.7.2
        "nelmio/cors-bundle": "^1.5", // 1.5.5
        "nesbot/carbon": "^2.17", // 2.19.2
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.1
        "symfony/asset": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/console": "4.2.*", // v4.2.12
        "symfony/dotenv": "4.2.*", // v4.2.12
        "symfony/expression-language": "4.2.*|4.3.*|4.4.*", // v4.3.11
        "symfony/flex": "^1.1", // v1.17.6
        "symfony/framework-bundle": "4.2.*", // v4.2.12
        "symfony/security-bundle": "4.2.*|4.3.*", // v4.3.3
        "symfony/twig-bundle": "4.2.*|4.3.*", // v4.2.12
        "symfony/validator": "4.2.*|4.3.*", // v4.3.11
        "symfony/yaml": "4.2.*" // v4.2.12
    },
    "require-dev": {
        "symfony/maker-bundle": "^1.11", // v1.11.6
        "symfony/stopwatch": "4.2.*|4.3.*", // v4.2.9
        "symfony/web-profiler-bundle": "4.2.*|4.3.*" // v4.2.9
    }
}
userVoice