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!

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