Input DTO: Denormalizing IRI Strings

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

Status update: we created a CheeseListingInput class with all the properties and groups that we need, we've configured CheeseListing to use that and we've started creating a data transformer that will take the CheeseListingInput and convert it into a CheeseListing whenever somebody sends JSON to a POST or PUT endpoint.

supportsTransformation()

In supportsTransformation(), we're dumping $data, $to and $context:

... lines 1 - 6
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 9 - 12
public function supportsTransformation($data, string $to, array $context = []): bool
{
dump($data, $to, $context);
... lines 16 - 17
}
}

When we look over at the profiler... it's slightly different than what we saw with our output transformer. This time, the data is an array: it's the decoded JSON that we sent. The $to is the target class - CheeseListing - and the $context is also interesting: it has an input key with class set to CheeseListingInput.

Let's use this to fill in supportsTransformation(). Start by checking if $data is an instanceOf CheeseListing, which in our dump, it is not. But if it is, then return false:

... lines 1 - 6
use App\Entity\CheeseListing;
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 11 - 17
public function supportsTransformation($data, string $to, array $context = []): bool
{
if ($data instanceof CheeseListing) {
// already transformed
return false;
}
... lines 24 - 25
}
}

This would mean that the object has already been transformed.

To be honest... I'm not sure if this is needed... but it's shown on the docs. There might be some case where a transformer is called multiple times.

The real important part is the return down here: we support this transformation if $to === CheeseListing::class and $context['input']['class'] - or null if it's not set - equals CheeseListingInput::class:

... lines 1 - 5
use App\Dto\CheeseListingInput;
use App\Entity\CheeseListing;
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 11 - 17
public function supportsTransformation($data, string $to, array $context = []): bool
{
if ($data instanceof CheeseListing) {
// already transformed
return false;
}
return $to === CheeseListing::class && ($context['input']['class'] ?? null) === CheeseListingInput::class;
}
}

The first part makes sense: we want to return true if we are converting into a CheeseListing. And the second part isn't needed in our app because we know that CheeseListing will always use CheeseListingInput as its input class. But since you can have different input classes on different operations, the $context tells us which input class should be used for this operation. We're using that to double-check.

Anyways, let's see if this is working! Dump all three arguments again inside of transform(). And, to avoid an error, return an empty CheeseListing object at the bottom:

... lines 1 - 8
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
public function transform($object, string $to, array $context = [])
{
dump($object, $to, $context);
return new CheeseListing();
}
... lines 17 - 26
}

Now spin over to the browser - I'll leave the profiler open - and hit "Execute" to try it again. Let's see: it's the same 400 validation error as before... which makes sense! We're returning a new, empty CheeseListing.

Back on the profiler tab, hit "Latest", which should take us to the profiler for the latest request. Yep! This dump is coming from line 13, which proves our supportsTransformation() is working!

Let's rename $object to $input and add some PHPDoc: we know this will be a CheeseListingInput: the result of deserializing the JSON:

... lines 1 - 8
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
/**
* @param CheeseListingInput $input
*/
public function transform($input, string $to, array $context = [])
{
dump($input, $to, $context);
... lines 17 - 18
}
... lines 20 - 29
}

So... now our job is simple, right? Take this CheeseListingInput object, move the data over onto a CheeseListing and return it. What could go wrong?

Denormalizing IRIs into Objects

But... wait a second: check out the dump again in the profiler. The JSON has been deserialized into this CheeseListingInput object, which is cool, but look at the owner field. It's a string! I mean... that makes sense... because we are sending this string in our JSON. But... does that mean we're supposed to query for the User object manually using this IRI?

Nope! One of the jobs of the deserializer is to take each piece of data in the JSON that we're sending and figure out what type it should be, like a string DateTime object or User object. Then it does whatever work is needed to change the raw data into that type - like creating a DateTime object from a date string. There are various normalizers that are good at doing this.

And... fortunately API Platform comes with an ItemNormalizer whose job is to change IRI strings into objects by querying the database... or... more accurately, by calling the item data provider.

So why isn't that happening here? Why isn't the IRI string being changed into a User object? Check out the CheeseListingInput class and look at the owner property:

... lines 1 - 7
class CheeseListingInput
{
... lines 10 - 19
/**
* @Groups({"cheese:collection:post"})
*/
public $owner;
... lines 24 - 43
}

The deserializer has no idea that this property is supposed to hold a User object! And so, that ItemNormalizer doesn't know it should do its work!

Let's help it: above this add @var User:

... lines 1 - 4
use App\Entity\User;
... lines 6 - 8
class CheeseListingInput
{
... lines 11 - 20
/**
* @var User
... line 23
*/
public $owner;
... lines 26 - 45
}

Head back to the browser, hit Execute and... once that finishes, go to the Profiler, hit Latest and tada! The owner is now a User object! Adding proper types to your properties is very important for deserialization.

Next, there's a small bug in the documentation that we actually just found a way to work around. Let's see what it is and finish our data transformer.

Leave a comment!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.2.5",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "api-platform/core": "^2.1", // v2.5.7
        "composer/package-versions-deprecated": "^1.11", // 1.11.99
        "doctrine/annotations": "^1.0", // 1.10.4
        "doctrine/doctrine-bundle": "^2.0", // 2.1.2
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.0.1
        "doctrine/orm": "^2.4.5", // v2.7.3
        "nelmio/cors-bundle": "^2.1", // 2.1.0
        "nesbot/carbon": "^2.17", // 2.39.1
        "phpdocumentor/reflection-docblock": "^3.0 || ^4.0", // 4.3.4
        "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.9.10
        "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.7.3
        "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.21.1
        "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.1.2
    }
}