Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

DTO 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

To get an output DTO class to... ya know... actually work, we need to write code that converts our CheeseListing object into a CheeseListingOutput object so that API Platform can serialize it. The "thing" that does that is called a data transformer.

Creating the Data Transformer Class

Let's create one in the src/ directory: add a new directory called DataTransformer for organization and a class inside called CheeseListingOutputDataTransformer:

... lines 1 - 2
namespace App\DataTransformer;
... lines 4 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
... lines 11 - 19
}

As usual with a class that hooks into part of API Platform, this needs to implement an interface. In this situation, it's - surprise! - DataTransformerInterface!

... lines 1 - 4
use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
... lines 6 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
... lines 11 - 19
}

Inside the class, go to "Code"->"Generate" - or Command+N on a Mac - and select "Implement Methods" to generate the two we need:

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

The Data Transformer System

So here's how this works: the data transformer system exists entirely to support the input and output DTO classes that we're working with. Whenever API Platform is about to serialize an object, it checks to see if that resource has an output DTO - which we configured in CheeseListing's @ApiResource annotation. If it does, it loops over every data transformer in the system and calls supportsTransformation().

It basically asks each one:

Hey! Apparently I need to transform a CheeseListing into a CheeseListingOutput. Do... you know how to do that?

And thanks to auto-configuration, because our new class implements DataTransformerInterface, it's instantly part of that system. In other words, our supportsTransformation() method should now be called!

supportsTransformation()

To prove it is, lets dd($data) and $to:

... lines 1 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
... lines 11 - 15
public function supportsTransformation($data, string $to, array $context = []): bool
{
dd($data, $to);
}
}

Now, move over and refresh the endpoint. There it is! API Platform is passing us the CheeseListing and for the $to argument, it's asking:

Do you know how to convert this CheeseListing into CheeseListingOutput?

And we do! For supportsTransformation(), return $data instanceof CheeseListing and $to === CheeseListingOutput::class:

... lines 1 - 5
use App\Dto\CheeseListingOutput;
use App\Entity\CheeseListing;
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
... lines 11 - 15
public function supportsTransformation($data, string $to, array $context = []): bool
{
return $data instanceof CheeseListing && $to === CheeseListingOutput::class;
}
}

That second part might seem unnecessary... since, in our app, a CheeseListing will always have CheeseListingOutput as its output class. But technically, you can configure a different output class on an operation-by-operation basis. So, we're checking it to be safe.

As soon as one of the data transformers returns true from supportsTransformation(), API Platform will call transform() so that we can do our work. To make sure that's happening, dd($object) and $to:

... lines 1 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
public function transform($object, string $to, array $context = [])
{
dd($object, $to);
}
... lines 15 - 19
}

When we move over and refresh... yes! It dumps the exact same thing.

transform()

Back in transform(), we know that $object will be a CheeseListing object. Let's rename $object to $cheeseListing and then, above this, add PHPDoc to tell my editor that this will be a CheeseListing object:

... lines 1 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
/**
* @param CheeseListing $cheeseListing
*/
public function transform($cheeseListing, string $to, array $context = [])
{
... lines 16 - 19
}
... lines 21 - 25
}

Ok: our job in transform() is pretty simple: return a CheeseListingOutput object. Let's do this as simply as we can: $output = new CheeseListingOutput(). And then, the only field we have right now is title. Populate that with $output->title = $cheeseListing->getTitle(). At the bottom, return $output:

... lines 1 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
... lines 11 - 13
public function transform($cheeseListing, string $to, array $context = [])
{
$output = new CheeseListingOutput();
$output->title = $cheeseListing->getTitle();
return $output;
}
... lines 21 - 25
}

Let's do this! Move back over, refresh and... um... it kind of works? We are getting results... but each one only has @id?

I have two questions about this. First... what happened to @type? Each item usually has at least @id and @type. So where is @type hiding? We'll talk about why that's missing a bit later.

Normalization Groups Still Apply to DTO's!

But before that, my second question is: why don't we see the title field? That has a simpler answer: normalization groups.

On CheeseListing, we set a normalizationContext with groups set to cheese:read:

... lines 1 - 20
/**
* @ApiResource(
... line 23
* normalizationContext={"groups"={"cheese:read"}},
... lines 25 - 47
* )
... lines 49 - 61
*/
class CheeseListing
{
... lines 65 - 221
}

Thanks to the output DTO, what's actually being serialized now is a CheeseListingOutput object. But, the normalization groups still apply.

In other words, in CheeseListingOutput, we need to add that group above any properties that we want to serialize. Above title, say @Groups(), go copy the group name, and paste it here: cheese:read:

... lines 1 - 4
use Symfony\Component\Serializer\Annotation\Groups;
class CheeseListingOutput
{
/**
* @Groups({"cheese:read"})
*/
public $title;
}

Now when we try it... sweet! We have a title field!

Next: let's add the rest of the properties we need to CheeseListingOutput and see how all of this looks in our documentation. Because... similar to DailyStats, since this is not an entity, we're going to need to do a bit more work to help API Platform understand the types of each property.

Leave a comment!

1
Login or Register to join the conversation
Lucien D. Avatar
Lucien D. Avatar Lucien D. | posted 1 year ago | edited

Hi,

Thanks for your tutorials, they rock !!!

I'm having trouble using dtos with validation groups. Validation works but the validation_groups set in the Entity class attribute is not interpreted on the dataTransformer call.

My Entity definition :


#[ApiResource(
    collectionOperations: [
        "post" => [
            "security_post_denormalize" => "is_granted('LINE_CREATE', object)",
            "validation_groups" => ["Default", "create"],
            "openapi_context" => [
                "security" => ['bearerAuth' => []]
            ]
        ],
    ],
    itemOperations: [
        "delete" => ["security" => "is_granted('LINE_DELETE', object)"],
        "get" => ["security" => "is_granted('LINE_VIEW', object)"],
        "patch" => ["security" => "is_granted('LINE_EDIT', object)"],
    ],
    input: LineInput::class,
    output: LineOutput::class,
    security: "is_granted('ROLE_USER')"
)]
#[ORM\EntityListeners([LineSetSocietyListener::class])]
#[UniqueLine]
class Line implements NetworkElementInterface


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