Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Input Data Transformer

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

Adding the @var User above the owner property was enough for the denormalizer to automatically convert the IRI string we're sending in our JSON into a proper User object. Yay! And this also fixed something in our documentation. Go back to the docs tab... actually, I'll open a new tab so I don't lose my testing data.

On the original tab, until now, when we hit "Try it out", it only listed the description field in the example JSON. The docs didn't think that title, owner and price were fields that were allowed to be sent.

But now, on the new version of the docs, when we hit "Try it out"... it does now recognize that owner is a field we can send.

So... what's going on? It looks like there's a little bug with input DTOs where the documentation doesn't notice that a field exists until it has some metadata on it. So as soon as we added the type to owner, suddenly the documentation noticed it!

And... that's fine because we do want types on all of our properties. Back in the class, above title, add @var string, @var int for price and above isPublished, @var bool:

... lines 1 - 8
class CheeseListingInput
{
/**
* @var string
... line 13
*/
public $title;
/**
* @var int
... line 19
*/
public $price;
... lines 22 - 28
/**
* @var bool
... line 31
*/
public $isPublished = false;
... lines 34 - 48
}

By the way, if you're wondering why description was always in the docs, remember that the description field comes from the setTextDescription() method, which does have metadata above it and an argument with a type-hint:

... lines 1 - 8
class CheeseListingInput
{
... lines 11 - 36
/**
* The description of the cheese as raw text.
*
* @Groups({"cheese:write", "user:write"})
* @SerializedName("description")
*/
public function setTextDescription(string $description): self
{
... lines 45 - 47
}
}

Let's check the docs now: refresh, go back to the POST endpoint, hit, "Try it out" and... yes! Now it sees all the fields.

Finishing the transform Logic

Ok: let's finish our data transformer. Instead of returning, say $cheeseListing = new CheeseListing() and pass the title as the first argument: $input->title:

... lines 1 - 8
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 11 - 13
public function transform($input, string $to, array $context = [])
{
... lines 16 - 17
$cheeseListing = new CheeseListing($input->title);
... lines 19 - 24
}
... lines 26 - 35
}

Then, some good, boring work: $cheeseListing->setDescription($input->description), $cheeseListing->setPrice($input->price), $cheeseListing->setOwner($input->owner) - which is a User object - and $cheeseListing->setIsPublished($input->isPublished). Return $cheeseListing at the bottom:

... lines 1 - 8
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 11 - 13
public function transform($input, string $to, array $context = [])
{
... lines 16 - 17
$cheeseListing = new CheeseListing($input->title);
$cheeseListing->setDescription($input->description);
$cheeseListing->setPrice($input->price);
$cheeseListing->setOwner($input->owner);
$cheeseListing->setIsPublished($input->isPublished);
return $cheeseListing;
}
... lines 26 - 35
}

Okay: moment of truth. I'll close the extra tab, go back to the original documentation tab, hit "Execute" and... it fails:

Argument 1 passed to CheeseListing::setPrice() must be of type int, null given.

The problem is that I forgot to pass a price field up in the JSON, which causes the type error. We're going to talk more about this later when we chat about validation, but for now, be sure to pass every field we need, like price: 2000.

Try it again. And... bah! I get the same error for the setIsPublished() method. I really meant to default isPublished to false in CheeseListingInput:

... lines 1 - 8
class CheeseListingInput
{
... lines 11 - 32
public $isPublished = false;
... lines 34 - 48
}

Ok, one more time. And... yes! A 201 status code. It worked!

So using a DTO input is a 3-step process. First, API Platform deserializes the JSON we send into a CheeseListingInput object. Second, we transform that CheeseListingInput into a CheeseListing in the data transformer. And third, the normal Doctrine data persister saves things. That's a really clean process!

Go back to the docs and look at the put operation that updates cheeses. Will this work? Well, we do have a data transformer... so... why wouldn't it? Well, it won't quite work yet. Why not? Because our data transformer always creates new CheeseListing objects... which would cause Doctrine to make an INSERT query even though we're trying to update a record.

Next: let's make this work! It's... a bit trickier than it may seem at first.

Leave a comment!

2
Login or Register to join the conversation

Hey there,

For some reason this wasn't working for me.
I was getting a 500 error like this one.

<blockquote>A new entity was found through the relationship 'App\Entity\CheeseNotification#cheeseListing' that was not configured to cascade persist operations for entity: App\Entity\CheeseListing@000000000c8e85fb0000000038202b1e. To solve this issue: Either explicitly call EntityManager#persist() on this unknown entity or configure cascade persist this association in the mapping for example @ManyToOne(..,cascade={"persist"}). If you cannot find out which entity causes the problem implement 'App\Entity\CheeseListing#__toString()' to get a clue.</blockquote>

So I went in App\DataPersister\CheeseListingDataPersister and change the persist function.


    /**
     * @param CheeseListing $data
     */
    public function persist($data)
    {
        $originalData        = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
        $wasAlreadyPublished = $originalData['isPublished'] ?? false;

        if ($data->getIsPublished() && !$wasAlreadyPublished) {
            $notification = new CheeseNotification($data, 'Cheese listing was created!');
            $this->entityManager->persist($notification);
            $this->entityManager->flush();
        }

        $this->decoratedDataPersister->persist($data);
    }

Became


    /**
     * @param CheeseListing $data
     */
    public function persist($data)
    {
        $originalData        = $this->entityManager->getUnitOfWork()->getOriginalEntityData($data);
        $wasAlreadyPublished = $originalData['isPublished'] ?? false;

        $this->decoratedDataPersister->persist($data);

        if ($data->getIsPublished() && !$wasAlreadyPublished) {
            $notification = new CheeseNotification($data, 'Cheese listing was created!');
            $this->entityManager->persist($notification);
            $this->entityManager->flush();
        }
    }

In short, I had to move up the $this->decoratedDataPersister->persist($data); so its already persisted before the persist and flush the notification.

Thought it could help.

1 Reply

Hey julien_bonnier !

Interesting - thanks for sharing this! I'm not sure why you had the problem and I didn't, but, either way, I like your version better. Best to persist both the CheeseListing and CheeseNotification and THEN flush. I appreciate you posting this!

Cheers!

Reply
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