Using the Symfony Serializer with Doctrine relations — Part 1
The Symfony Serializer component is a very powerful and useful component when writing an API. It handles the conversion between your request data (JSON, XML, anything really…) and your classes (usually your entities).
If you're not familiar with the Symfony Serializer component, please read the documentation first.
The reason I wrote this article is because although it's awesome, by default it does not handle relations with existing doctrine entities.
The problem
I was working on an API with Symfony 4 and had quite a struggle with the serializer when deserializing entities with existing relations.
Let's say we have a simple entity BookShelf
with contains nothing but an ID
and a one-to-many
relation with Book
. A very simple example with one BookShelf
which can have many Book
s.
For the sake of brevity I won't include the Doctrine mapping here but you can find all about it in their documentation.
Creating the controller
Now say we have an API endpoint where you can submit a new Book
and add it to a new BookShelf
. Using the Serializer component this is as simple as these few lines:
Creating a book
Now when you send a POST
request like this, the serializer transforms our JSON into a new Book
but as it's smart, it also creates a new BookShelf
and adds it to our book as shelf. For this to work it's important you enable the property_info
in your framework settings, see here.
So far so good, pretty epic right?
Well now the part which caused me some headaches. Say we have already created a shelf which has ID 3
and we want to add a new book to it. To me it made sense you just set shelf
property of your book to the ID of the shelf like this:
Unfortunately that leads to this beautiful error message:
The property path constructor needs a string or an instance of “Symfony\Component\PropertyAccess\PropertyPath”. Got: “integer”
As you'll hopefully understand, this error didn't get me very far. Actually changing the shelf value from an integer 3
to a string "3"
still gave me the exact same error message. Whaat? yes.
Okay so what if I was wrong and shelf
needed to be an object with its id
set to the shelf we want to add our book to? Unfortunately that doesn't work either and instead the shelf ID is completely ignored. As you can see below a new shelf is created with an ID of 21
instead of 3
like we told it to.
Why doesn't it work?!
What happens is that the denormalizer stumbles upon the shelf
property, finds it in our Book
entity and sees it's value should be aBookShelf
. In turn it starts denormalizing the value of shelf
and expecting the data to represent a BookShelf
and thus an object.
Instead of an object we gave it a string or an integer representing the ID of the BookShelf
and not an object representing the BookShelf
itself, so it freaks out and throws this very -cough- helpful exception.
The serializer checks each registered normalizer until one has been found that that says to support denormalization of the data (in our case 3
and property type (in our case BookShelf
), this is done by calling supportsNormalization()
on the normalizer. In our case there was no normalizer configured that supported denormalization of our BookShelf
other than the ObjectNormalizer
.. which misjudged the situation and threw an error later on.
The solution
In order for the serializer to understand what to do with our BookShelf
id we need to write our own (de)normalizer. Our normalizer will take our id and look it up in the database. When it's found it'll return our BookShelf
, when it's not it'll simply return null
.
Now this sounds harder than it is, have a look yourself:
Make sure you register the normalizer as a service and tag it with the serializer.normalizer
tag. Otherwise the serializer won't know of its existence and stuff still won't work.
What is this sorcery?
The first step in the process is to deserialize the JSON data into an array so PHP can work with the data. The next step is to denormalize the data into an actual object (our BookShelf
). This is where stuff broke and where our EntityNormalizer
comes in to fix it.
When the serializer attempts to denormalize data, it calls supportsDenormalization
on all registered normalizers in order of priority. The first to say yes gets the job. Since our EntityNormalizer
has a priority of 0
(default) and all other normalizers have a much lower priority, ranging from -900
to -1000
, it means we get to choose first!
We check in supportsDenormalization()
if the property which we're looking to denormalize is in a namespace starting withApp\Entity
. Next we also want to make sure the data we're dealing with is either an integer or a string as it must represent a database id.
Actually denormalizing the data is nothing more than to tell Doctrine to go look for our entity by id and returning the result.
Giving it a spin…
Now let's repeat the POST
request we did earlier to create a new book and put it on shelf 3
. Let's actually repeat it a couple of times..
If all went well you should now see new Book
being created and put on BookShelf
number 3
. Repeat the request and you should see the array of books grow.
Thanks for reading my very first Medium article, be sure to give some claps if you enjoyed it. I hope this article saved someone a few sleepless nights.