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 SubscribeThe end goal is that this $input
argument will be the CheeseListingInput
object that we create in our denormalizer... which eventually will be pre-filled with data from the database:
... lines 1 - 9 | |
class CheeseListingInputDataTransformer implements DataTransformerInterface | |
{ | |
... lines 12 - 14 | |
public function transform($input, string $to, array $context = []) | |
{ | |
... lines 17 - 29 | |
} | |
... lines 31 - 40 | |
} |
Let's dump($input)
so that, once this ready, we can see if we've accomplished that:
... lines 1 - 9 | |
class CheeseListingInputDataTransformer implements DataTransformerInterface | |
{ | |
... lines 12 - 14 | |
public function transform($input, string $to, array $context = []) | |
{ | |
dump($input); | |
... lines 18 - 29 | |
} | |
... lines 31 - 40 | |
} |
As I mentioned earlier, as soon as we created this class and filled in supportsDenormalization()
:
... lines 1 - 9 | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
... lines 12 - 19 | |
public function supportsDenormalization($data, string $type, string $format = null) | |
{ | |
return $type === CheeseListingInput::class; | |
} | |
... lines 24 - 28 | |
} |
We became 100% responsible for denormalizing CheeseListingInput
objects... which means we actually do need to somehow take the array of data from the JSON, put it onto the CheeseListingInput
and return it!
But... pfff. We don't really want to do all that hard work... because that's what the normal denormalizer system does. Nope, let's just call the normal system, but pass in our modified $context
.
We actually had this same situation in an earlier tutorial with UserNormalizer
and we handled it with a - kind of - elaborate solution that allowed us to inject the entire normalizer system, call normalize()
again and use a flag on the $context
to avoid recursion:
... lines 1 - 11 | |
class UserNormalizer implements ContextAwareNormalizerInterface, CacheableSupportsMethodInterface, NormalizerAwareInterface | |
{ | |
use NormalizerAwareTrait; | |
... lines 15 - 27 | |
public function normalize($object, $format = null, array $context = array()) | |
{ | |
... lines 30 - 34 | |
$context[self::ALREADY_CALLED] = true; | |
return $this->normalizer->normalize($object, $format, $context); | |
} | |
public function supportsNormalization($data, $format = null, array $context = []) | |
{ | |
// avoid recursion: only call once per object | |
if (isset($context[self::ALREADY_CALLED])) { | |
return false; | |
} | |
return $data instanceof User; | |
} | |
... lines 49 - 65 | |
} |
The most correct solution in CheeseListingInputDenormalizer
would be to do that same thing. But... I'm going to cheat. Denormalization is a bit simpler than normalization... and we can get away with injecting the specific, one denormalizer that I know we need instead of injecting the entire denormalizer system and trying to avoid recursion.
The denormalizer we need is called ObjectNormalizer
and it's autowireable. On top, create public function __construct()
with ObjectNormalizer
- make sure you get the one from Symfony - $objectNormalizer
:
... lines 1 - 8 | |
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
... lines 13 - 14 | |
public function __construct(ObjectNormalizer $objectNormalizer) | |
{ | |
... line 17 | |
} | |
... lines 19 - 38 | |
} |
I'll hit Alt
+Enter
and go to "Initialize properties" to create that property and set it:
... lines 1 - 10 | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
private $objectNormalizer; | |
public function __construct(ObjectNormalizer $objectNormalizer) | |
{ | |
$this->objectNormalizer = $objectNormalizer; | |
} | |
... lines 19 - 38 | |
} |
Now, down in our code, return $this->objectNormalizer->denormalize()
and pass it the $data
- because we still want to denormalize the same array of data - $type
$format
, but now pass our shiny new $context
so that when it denormalizes, it will update our $dto
instead of creating a new one:
... lines 1 - 10 | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
... lines 13 - 19 | |
public function denormalize($data, string $type, string $format = null, array $context = []) | |
{ | |
... lines 22 - 26 | |
return $this->objectNormalizer->denormalize($data, $type, $format, $context); | |
} | |
... lines 29 - 38 | |
} |
Ok, let's try it! Hit "Execute" again. We still get a 500 error... which makes sense because we haven't finished our job yet. What I want to see is if the dump worked: I want to see if the CheeseListingInput
object we created in the denormalizer is now being passed into the transform()
method.
Go over to the other profiler tab and hit "Latest". And... there it is! Oh, wait. Ryan, you dummy! I should have taken off the title
field from the JSON so that we can see if the title
that we're setting in the denormalizer is what we see in the dump()
. Try it again... 500 error... hit Latest and... yes! This is what we were looking for! It proves that we are now in control of creating the CheeseListingInput
object. We create the object, then, when the deserializer does its job, it either leaves the property alone if we didn't send that field or it overrides it if we did send that field.
Now we are dangerous. Inside CheeseListingInputDataTransformer
, once we've finished pre-populating the $input
object from the database in the denormalizer, we will be able to safely transfer every property onto CheeseListing
because if a field was not sent in the JSON, it will match what's already in the database.
By the way, if you're wondering why we can override OBJECT_TO_POPULATE
to be a CheeseListingInput
inside the denormalizer:
... lines 1 - 10 | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
... lines 13 - 19 | |
public function denormalize($data, string $type, string $format = null, array $context = []) | |
{ | |
$dto = new CheeseListingInput(); | |
... lines 23 - 24 | |
$context[AbstractNormalizer::OBJECT_TO_POPULATE] = $dto; | |
... lines 26 - 27 | |
} | |
... lines 29 - 38 | |
} |
But then OBJECT_TO_POPULATE
is still apparently a CheeseListing
object inside of our data transformer:
... lines 1 - 9 | |
class CheeseListingInputDataTransformer implements DataTransformerInterface | |
{ | |
... lines 12 - 14 | |
public function transform($input, string $to, array $context = []) | |
{ | |
... line 17 | |
if (isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) { | |
$cheeseListing = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE]; | |
... lines 20 - 21 | |
} | |
... lines 23 - 29 | |
} | |
... lines 31 - 40 | |
} |
That's... a good thing to wonder!
In reality, denormalizing into the input object and calling the data transformer are two completely separate processes. The two $context
arrays - while nearly identical - are two separate arrays in memory. So even though we modify the $context
inside the denormalizer, that does not modify the $context
that's passed to the data transformer.
Anyways, let's finish the denormalizer by populating the CheeseListingInput
with the data from the database. And... since this is pretty boring, at the bottom, I'll paste a new private function. You can copy this from the code block on this page:
... lines 1 - 5 | |
use App\Entity\CheeseListing; | |
... lines 7 - 11 | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
... lines 14 - 40 | |
private function createDto(array $context): CheeseListingInput | |
{ | |
$entity = $context['object_to_populate'] ?? null; | |
$dto = new CheeseListingInput(); | |
// not an edit, so just return an empty DTO | |
if (!$entity) { | |
return $dto; | |
} | |
if (!$entity instanceof CheeseListing) { | |
throw new \Exception(sprintf('Unexpected resource class "%s"', get_class($entity))); | |
} | |
$dto->title = $entity->getTitle(); | |
$dto->price = $entity->getPrice(); | |
$dto->description = $entity->getDescription(); | |
$dto->owner = $entity->getOwner(); | |
$dto->isPublished = $entity->getIsPublished(); | |
return $dto; | |
} | |
} |
Re-type the end of CheeseListing
to get that use
statement:
... lines 1 - 5 | |
use App\Entity\CheeseListing; | |
... lines 7 - 65 |
This checks the object_to_populate
key - I should probably use the constant - to see if there is an existing entity, which would happen for an update operation. If there is no entity, which means this is a create operation, it returns an empty DTO. But if there is a CheeseListing
, it uses it to pre-populate all the fields on the DTO.
Back up in denormalize()
, delete the new DTO stuff and say $context[AbstractNormalizer::OBJECT_TO_POPULATE]
equals $this->createDto()
and pass $context
:
... lines 1 - 11 | |
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface | |
{ | |
... lines 14 - 20 | |
public function denormalize($data, string $type, string $format = null, array $context = []) | |
{ | |
$context[AbstractNormalizer::OBJECT_TO_POPULATE] = $this->createDto($context); | |
return $this->objectNormalizer->denormalize($data, $type, $format, $context); | |
} | |
... lines 27 - 60 | |
} |
Let's try it! Back at the browser, go to the docs tab... and hit Execute. Let's see... yes! It works! Price 5000!
We're still missing a few pieces related to validation, but we'll talk about those soon.
Before we do, we currently have code for converting to and from entity and DTO objects... all over the place. Next, it's cleanup time!
"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
}
}