Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

DTO Input Initializer Logic

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

The 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
}

Calling the Core Denormalizer System

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.

OBJECT_TO_POPULATE, CheeseListing & CheeseListingInput

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.

Pre-Populating from the Database

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!

Leave a comment!

0
Login or Register to join the conversation
Cat in space

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

This tutorial also works great with API Platform 2.6.

What PHP libraries does this tutorial use?

// 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
    }
}
userVoice