If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeWith an input DTO, the update process works like this. First ApiPlatform queries for the CheeseListing
entity. Second, the JSON is deserialized into a new CheeseListingInput
object. And third, our transform()
method is called, where we take that CheeseListingInput
object's data and move it onto the CheeseListing
. To get the CheeseListing
that was queried from the database, we grab it from the $context
:
... lines 1 - 9 | |
class CheeseListingInputDataTransformer implements DataTransformerInterface | |
{ | |
... lines 12 - 14 | |
public function transform($input, string $to, array $context = []) | |
{ | |
if (isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) { | |
$cheeseListing = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE]; | |
... lines 19 - 20 | |
} | |
... lines 22 - 28 | |
} | |
... lines 30 - 39 | |
} |
The problem with this process is step 2. Because the deserializer always creates a new CheeseListingInput
with no data before putting the JSON data onto it, if a property on the input is null
, we don't know if it's null
because the user actually sent null
for that field... or if the user simply didn't send the field at all and we should ignore it.
To fix this, before step 2, we need to somehow prepare a CheeseListingInput
object that's populated with the current data from the database and tell the serializer to deserialize the JSON onto that object instead of creating a new one. If we did that, it would be safe to set everything from the input object back onto the CheeseListing
because if a field was not sent, we would just be setting it to the original value from the database.
In ApiPlatform 2.6, you'll be able to do this via a new data transformer initializer. Since that's not released yet, we'll do it ourselves.
How? We know that if you set the OBJECT_TO_POPULATE
key on the $context
, then the deserializer will use that object instead of creating a new one:
... lines 1 - 9 | |
class CheeseListingInputDataTransformer implements DataTransformerInterface | |
{ | |
... lines 12 - 14 | |
public function transform($input, string $to, array $context = []) | |
{ | |
if (isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) { | |
$cheeseListing = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE]; | |
} else { | |
$cheeseListing = new CheeseListing($input->title); | |
} | |
... lines 22 - 28 | |
} | |
... lines 30 - 39 | |
} |
By leveraging a custom denormalizer, we could hook into the denormalization process and set the OBJECT_TO_POPULATE
key to a pre-populated CheeseListingInput
object right before the JSON is deserialized.
If... that doesn't make sense yet, it's okay. Let's step through it piece by piece.
To start, in the src/Serializer/Normalizer/
directory, create a new CheeseListingInputDenormalizer
class:
... lines 1 - 2 | |
namespace App\Serializer\Normalizer; | |
... lines 4 - 7 | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
... lines 10 - 20 | |
} |
This will be responsible for denormalizing CheeseListingInput
objects. Make it implement DenormalizerInterface
and also CacheableSupportsMethodInterface
, which is a performance thing:
... lines 1 - 4 | |
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; | |
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
... lines 10 - 20 | |
} |
Then go to "Code"->"Generate" - or Command
+N
a Mac - and select "Implement Methods" to generate the three methods we need. I'll move hasCacheableSupportsMethod()
to the bottom because it's the least important:
... lines 1 - 7 | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
public function denormalize($data, string $type, string $format = null, array $context = []) | |
{ | |
} | |
public function supportsDenormalization($data, string $type, string $format = null) | |
{ | |
} | |
public function hasCacheableSupportsMethod(): bool | |
{ | |
} | |
} |
As soon as we created this class, because it implements DenormalizerInterface
, the serializer will call supportsDenormalization()
on every single piece of data during denormalization. In supportsDenormalization()
, we support denormalizing a piece of data if its $type
equals CheeseListingInput::class
:
... lines 1 - 8 | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
... lines 11 - 15 | |
public function supportsDenormalization($data, string $type, string $format = null) | |
{ | |
return $type === CheeseListingInput::class; | |
} | |
... lines 20 - 24 | |
} |
Thanks to this, we are now 100% responsible for denormalizing CheeseListingInput
objects. Down in hasCacheableSupportsMethod()
return true, which you should do unless your supportsDenormalization()
method uses the $context
to make its decision:
... lines 1 - 8 | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
... lines 11 - 20 | |
public function hasCacheableSupportsMethod(): bool | |
{ | |
return true; | |
} | |
} |
Anyways, the serializer will now call denormalize()
whenever it's trying to denormalize a CheeseListingInput
. Let's dump()
the $context
- that's the last argument - so we can see what it looks like:
... lines 1 - 8 | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
public function denormalize($data, string $type, string $format = null, array $context = []) | |
{ | |
dump($context); | |
} | |
... lines 15 - 24 | |
} |
This won't work yet, but let's see what that dump looks like. At the browser - this is the put
operation - hit "Execute". And... error!
Expected denormalized input to be an object
It's complaining because we're not returning anything from our denormalize()
method yet... but we can check out the dump. In another tab, I already have my profiler open, click "Latest". This takes me to the "Exceptions" section. Go down and click to open the "Debug" section.
Nice! This is the $context
that's being passed to denormalize
. And check this out: it has an object_to_populate
key set to the CheeseListing
object. Well, really, that should be no surprise: we saw that a few minutes ago. Inside our data transformer, OBJECT_TO_POPULATE
is set to the existing CheeseListing
object:
... lines 1 - 9 | |
class CheeseListingInputDataTransformer implements DataTransformerInterface | |
{ | |
... lines 12 - 14 | |
public function transform($input, string $to, array $context = []) | |
{ | |
if (isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) { | |
$cheeseListing = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE]; | |
... lines 19 - 20 | |
} | |
... lines 22 - 28 | |
} | |
... lines 30 - 39 | |
} |
But... now that I think about it... the fact that this is set to a CheeseListing
object is kind of odd... because this process will ultimately deserialize the JSON into a CheeseListingInput
object. How does that work?
Internally, the serializer has a sanity check: if the OBJECT_TO_POPULATE
is not the same type as the object we're deserializing into, then it's ignored. That's what's happening now: API Platform sets the existing CheeseListing
onto OBJECT_TO_POPULATE
, but since we're not deserializing into that type of object, it's ignored and a new CheeseListingInput
is created:
... lines 1 - 9 | |
class CheeseListingInputDataTransformer implements DataTransformerInterface | |
{ | |
... lines 12 - 14 | |
public function transform($input, string $to, array $context = []) | |
{ | |
if (isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) { | |
... line 18 | |
} else { | |
$cheeseListing = new CheeseListing($input->title); | |
} | |
... lines 22 - 28 | |
} | |
... lines 30 - 39 | |
} |
But... we could change that key.
Inside the denormalizer, let's start with something simple: $dto = new CheeseListingInput()
and $dto->title =
some hardcoded title:
... lines 1 - 4 | |
use App\Dto\CheeseListingInput; | |
... lines 6 - 9 | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
public function denormalize($data, string $type, string $format = null, array $context = []) | |
{ | |
$dto = new CheeseListingInput(); | |
$dto->title = 'I am set in the denormalizer!'; | |
... lines 16 - 17 | |
} | |
... lines 19 - 28 | |
} |
Set this onto the context: $context[AbstractItemNormalizer::OBJECT_TO_POPULATE]
equals $dto
:
... lines 1 - 5 | |
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; | |
... lines 7 - 9 | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
public function denormalize($data, string $type, string $format = null, array $context = []) | |
{ | |
$dto = new CheeseListingInput(); | |
$dto->title = 'I am set in the denormalizer!'; | |
$context[AbstractNormalizer::OBJECT_TO_POPULATE] = $dto; | |
} | |
... lines 19 - 28 | |
} |
We're not done yet... but if we passed this $context
into the denormalizer system, then it should deserialize the JSON onto our new object. And, whatever we return from this method will eventually be passed as the $input
to our data transformer.
So next, let's finish this: by calling the denormalizer system to update the CheeseListingInput
, returning it from here, proving that it's passed to the data transformer, then finally pre-filling it with database data. If you can't see how all the pieces connect yet, you will soon.
"Houston: no signs of life"
Start the conversation!
// 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
}
}