Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Input DTO Class

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

If you liked the output DTO, we can do the same thing for handling the input data for a resource. Basically, we create a class that looks just like the input fields that are sent when creating or updating a CheeseListing and then API Platform will start deserializing the JSON to create that object. Then, our job - via a data transformer - will be to convert that input object into the final CheeseListing so that API Platform can save it.

Creating the CheeseListingInput

So... it's the exact same idea as the output, just... the other direction. Though, there will be a few interesting and tricky pieces.

Let's get started! In the src/Dto/ directory, create a new class called CheeseListingInput:

... lines 1 - 2
namespace App\Dto;
... lines 4 - 7
class CheeseListingInput
{
... lines 10 - 43
}

This time, let's move all of the fields that we can currently send to create or update a CheeseListing - like title and price, into here.

Start with public $title and put some PHPDoc on it. Then in CheeseListing, steal @Groups, delete it - we won't need any groups here anymore - and paste the @Groups on top of the new title property:

... lines 1 - 4
use Symfony\Component\Serializer\Annotation\Groups;
... lines 6 - 7
class CheeseListingInput
{
/**
* @Groups({"cheese:write", "user:write"})
*/
public $title;
... lines 14 - 43
}

Oh, but, I'll re-type the end of this and hit tab so that PhpStorm adds the use statement for me:

... lines 1 - 4
use Symfony\Component\Serializer\Annotation\Groups;
... lines 6 - 45

The other fields we need are public $price, owner and isPublished. Let's go steal their groups: find price, move its @Groups over, then for owner, do the same... and finally, grab the @Groups for isPublished:

... lines 1 - 7
class CheeseListingInput
{
... lines 10 - 14
/**
* @Groups({"cheese:write", "user:write"})
*/
public $price;
/**
* @Groups({"cheese:collection:post"})
*/
public $owner;
/**
* @Groups({"cheese:write"})
*/
public $isPublished = false;
... lines 29 - 43
}

There is one other field: search for groups. Yep, setTextDescription():

... lines 1 - 62
class CheeseListing
{
... lines 65 - 145
/**
* The description of the cheese as raw text.
*
* @Groups({"cheese:write", "user:write"})
* @SerializedName("description")
*/
public function setTextDescription(string $description): self
{
$this->description = nl2br($description);
return $this;
}
... lines 158 - 198
}

This allows the user to send a description field... but ultimately the deserialization process calls setTextDescription() and then we call nl2br on it. We want to do the exact same thing in the input class. So, copy this method, delete it, and paste it at the bottom of CheeseListingInput. Re-type the end of @SerializedName and auto-complete it to get the use statement:

... lines 1 - 7
class CheeseListingInput
{
... lines 10 - 31
/**
* The description of the cheese as raw text.
*
* @Groups({"cheese:write", "user:write"})
* @SerializedName("description")
*/
public function setTextDescription(string $description): self
{
$this->description = nl2br($description);
return $this;
}
}

Of course, when the deserializer calls this method, we're storing the end result on a description property... which doesn't exist yet. Let's add it: public $description:

... lines 1 - 7
class CheeseListingInput
{
... lines 10 - 29
public $description;
... lines 31 - 43
}

But we're not going to put this in any groups because we don't want this field to be writable directly: it's just there to store data.

Ok! Back in CheeseListing, if we search for "Groups", cool! The gray means that both the Groups and SerializedName use statements are not needed anymore because we have moved all of this stuff. Delete both:

... lines 1 - 16
use Symfony\Component\Serializer\Annotation\Groups;
use Symfony\Component\Serializer\Annotation\SerializedName;
... lines 19 - 200

There is now nothing inside of CheeseListing about serializing or deserializing.

Ok! Our CheeseListingInput is ready! To tell API Platform to use it, it's the same as output. On CheeseListing, add input=, remove the quotes, and say CheeseListingInput::class. Don't forget to add the use statement manually: use CheeseListingInput:

... lines 1 - 11
use App\Dto\CheeseListingInput;
... lines 13 - 20
/**
* @ApiResource(
... line 23
* input=CheeseListingInput::CLASS,
... lines 25 - 48
* )
... lines 50 - 63
class CheeseListing
{
... lines 66 - 182
}

How Deserializing Works

We don't have a data transformer yet, but this should be enough to get this to show up in our docs. Find your browser and refresh the docs homepage. Go down to the POST endpoint for cheeses and hit "Try it". And... Oh! Interesting. It only shows the description field here, which is odd... but let's ignore that for now.

Try the endpoint with title, description and a valid owner: /api/users/1. Double-check your database to make sure that's a real user.

Testing time! Hit Execute and... 400 error! We didn't expect it to work yet, but the way it doesn't work is the cool part: we get a bunch of "this value should not be blank" errors on title, description and price... even though we did send some of these fields!

Here's what's going on: thanks to the input= we added, when we send JSON to a CheeseListing operation, the serializer is now taking that JSON and deserializing it into a CheeseListingInput object - not a CheeseListing object.

But... because we haven't created a data transformer yet, nothing ever takes that CheeseListingInput and converts it into a CheeseListing. So... API Platform just creates an empty CheeseListing... then runs validation on that empty object.

To fix this, we know the answer: we need a data transformer!

Creating the DataTransformer

Inside of the DataTransformer directory, create a new PHP class called CheeseListingInputDataTransformer. Make this implement, of course, DataTransformerInterface:

... lines 1 - 2
namespace App\DataTransformer;
use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 9 - 15
}

And then go to "Code"->"Generate" - or Command + N on a Mac - to generate the two methods we need:

... lines 1 - 6
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
public function transform($object, string $to, array $context = [])
{
}
public function supportsTransformation($data, string $to, array $context = []): bool
{
}
}

This time, the supportsTransformation() method will look a little bit different. Dump all three arguments... and actually use dump() instead of dd() because we're going to test this inside the interactive docs... and reading HTML inside the response is ugly there. Using dump() will save the HTML to the profiler so we can easily look at it.

Anyways, dump $data, $to and $context. And at the bottom return false so this method doesn't cause an error: it needs to return a boolean:

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

Ok: move over, hit "Execute" and... huh? It... actually did dump the variables right in the response... which is what I was trying to avoid! Normally, if you use dump(), it doesn't dump in the response, it instead saves it to the profiler.

The reason this isn't happening... is that I'm missing a bundle that adds the integration between the dump() function and the profiler. To install it, find your terminal and run:

composer require symfony/debug-bundle

Once this finishes... we should be able to go back to the browser, hit Execute again and... perfect! A 400 error JSON response, but no HTML.

To see the dumped variables, go down to the web debug toolbar and open the last request's profiler in a new tab. Nice! It automatically took me to the Debug section: here are the $data, $to and $context variables.

Next: let's use this information to finish our data transformer and get this thing working!

Leave a comment!

2
Login or Register to join the conversation
Default user avatar
Default user avatar Gianluca | posted 5 months ago

Hi
I have a question. Let I have a Resource, Book Entity, a standard ApiResource, with a REST operation POST for adding new Entity.
Let I have a new partner that want to add an Entity using API, BUT ... this partner can send a JSON that is totally different from standard POST Api.
Which is the best option to handle this situation? I think that DTO could be useful , I can declare a PartnerXBookDTO, but should I create a new custom operation? If I create a custom operation, should I create the entire ControllerAction or I can use some kind of Listener?

Reply

Hey Gianluca!

Sorry for the slow reply - the team left this tougher question for me :). Hmm... I'm not really sure what the "best" way to handle this would be. So yes, I'd probably try this with a DTO. In theory, if you create a DTO that looks like the JSON, then the JSON will deserializer into the DTO, then you can transform it back into a Book. Should you create a custom operation? That depends on whether you need to keep the "original" POST action or if having this new POST action is the only you need. If it's the only you need, then skip the custom operation and make the original POST operation use your DTO. If you DO need a custom operation... I'm not sure whether you would need a custom controller or not... it might be enough to create the custom operation and configure it with the DTO (and no controller is needed).

I'm going some guessing here about the way to handle things, but let me know if this helps or if you found a nice solution :).

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": "^7.4.0 || ^8.0",
        "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
    }
}