Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

DTO Class Organization

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

It took some work - especially getting the update to work before API Platform 2.6 - but our input & output DTO system is alive! Though... our logic for converting from CheeseListing to input, input to CheeseListing and CheeseListing to output is... not super organized. This code is all over the place. We can do better.

There's no right or wrong way to organize this kind of data transformation code, but let's see what we can figure out. Start in CheeseListingInputDataTransformer: this is where we go from CheeseListingInput to CheeseListing. I'm going to put all of this transformation code into the DTO classes themselves... because that's really their job: to be a helper class for data as it goes from one place to another.

CheeseListingInput::createOrUpdateEntity

In CheeseListingInput create a new public function - createOrUpdateEntity() - with a nullable ?CheeseListing $cheeseListing argument and this will return a CheeseListing:

... lines 1 - 4
use App\Entity\CheeseListing;
... lines 6 - 9
class CheeseListingInput
{
... lines 12 - 37
public function createOrUpdateEntity(?CheeseListing $cheeseListing): CheeseListing
{
... lines 40 - 49
}
... lines 51 - 63
}

The reason this is nullable is because, inside the data transformer, we may or may not have an existing CheeseListing.

Start inside of CheeseListingInput with a check for that: if not $cheeseListing, then $cheeseListing = new CheeseListing(). And of course, this is where we pass in the title, which is now $this->title:

... lines 1 - 9
class CheeseListingInput
{
... lines 12 - 37
public function createOrUpdateEntity(?CheeseListing $cheeseListing): CheeseListing
{
if (!$cheeseListing) {
$cheeseListing = new CheeseListing($this->title);
}
... lines 43 - 49
}
... lines 51 - 63
}

For the rest of the logic, copy the setters from the transformer... then paste them here. Oh, and change $input to $this on all the lines. At the bottom, return $cheeseListing:

... lines 1 - 9
class CheeseListingInput
{
... lines 12 - 37
public function createOrUpdateEntity(?CheeseListing $cheeseListing): CheeseListing
{
if (!$cheeseListing) {
$cheeseListing = new CheeseListing($this->title);
}
$cheeseListing->setDescription($this->description);
$cheeseListing->setPrice($this->price);
$cheeseListing->setOwner($this->owner);
$cheeseListing->setIsPublished($this->isPublished);
return $cheeseListing;
}
... lines 51 - 63
}

How nice is that? You could even unit test this!

Back in the data transformer, to use this, copy the $cheeseListing context line, delete the top section, paste and add ?? null:

... lines 1 - 9
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 12 - 14
public function transform($input, string $to, array $context = [])
{
$cheeseListing = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? null;
... lines 18 - 19
}
... lines 21 - 30
}

At this point, $cheeseListing with either be a CheeseListing object or null. Finish the method with return $input->createOrUpdateEntity($cheeseListing):

... lines 1 - 9
class CheeseListingInputDataTransformer implements DataTransformerInterface
{
... lines 12 - 14
public function transform($input, string $to, array $context = [])
{
$cheeseListing = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? null;
return $input->createOrUpdateEntity($cheeseListing);
}
... lines 21 - 30
}

That is beautiful.

CheeseListingInput::createFromEntity

Next, go to the denormalizer. This is where we go the other direction - from a CheeseListing - which might be null - into a CheeseListingInput.

Once again, let's put the logic inside CheeseListingInput, this time as a public static function - called createFromEntity() - that accepts a nullable CheeseListing argument and returns self:

... lines 1 - 9
class CheeseListingInput
{
... lines 12 - 37
public static function createFromEntity(?CheeseListing $cheeseListing): self
{
... lines 40 - 53
}
... lines 55 - 81
}

Go steal code from the denormalizer... copy the center section, paste, and update the first $entity argument to $cheeseListing:

... lines 1 - 9
class CheeseListingInput
{
... lines 12 - 37
public static function createFromEntity(?CheeseListing $cheeseListing): self
{
$dto = new CheeseListingInput();
// not an edit, so just return an empty DTO
if (!$cheeseListing) {
return $dto;
}
... lines 46 - 53
}
... lines 55 - 81
}

Delete the instanceof check - we'll keep that in the denormalizer - and update the last $entity variables to $cheeseListing. Finally, return $dto:

... lines 1 - 9
class CheeseListingInput
{
... lines 12 - 37
public static function createFromEntity(?CheeseListing $cheeseListing): self
{
$dto = new CheeseListingInput();
// not an edit, so just return an empty DTO
if (!$cheeseListing) {
return $dto;
}
$dto->title = $cheeseListing->getTitle();
$dto->price = $cheeseListing->getPrice();
$dto->description = $cheeseListing->getDescription();
$dto->owner = $cheeseListing->getOwner();
$dto->isPublished = $cheeseListing->getIsPublished();
return $dto;
}
... lines 55 - 81
}

Back in the denormalizer, life is a lot simpler! Keep the first line that gets the entity or sets it to null, delete the next part, keep the instanceof check, but add if $entity && at the beginning:

... lines 1 - 11
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface
{
... lines 14 - 37
private function createDto(array $context): CheeseListingInput
{
$entity = $context['object_to_populate'] ?? null;
if ($entity && !$entity instanceof CheeseListing) {
throw new \Exception(sprintf('Unexpected resource class "%s"', get_class($entity)));
}
... lines 45 - 46
}
}

So if we do have an entity and it's somehow not a CheeseListing... we should panic.

At the bottom, return CheeseListingInput::createFromEntity($entity):

... lines 1 - 11
class CheeseListingInputDenormalizer implements DenormalizerInterface, CacheableSupportsMethodInterface
{
... lines 14 - 37
private function createDto(array $context): CheeseListingInput
{
$entity = $context['object_to_populate'] ?? null;
if ($entity && !$entity instanceof CheeseListing) {
throw new \Exception(sprintf('Unexpected resource class "%s"', get_class($entity)));
}
return CheeseListingInput::createFromEntity($entity);
}
}

I love that.

CheeseListingOutput::createFromEntity()

Let's clean up one more spot. Open CheeseListingOutputDataTransformer. This is where we go from CheeseListing to CheeseListingOutput. Let's move this into CheeseListingOutput. Once again, it will be static: public static function createFromEntity() with a CheeseListing argument - we know this will never be null - and the method will return self:

... lines 1 - 4
use App\Entity\CheeseListing;
... lines 6 - 9
class CheeseListingOutput
{
... lines 12 - 39
public static function createFromEntity(CheeseListing $cheeseListing): self
{
... lines 42 - 49
}
... lines 51 - 72
}

Go steal all the code from the output transformer... and paste it here. If you want, you can change this to new self()... but nothing else needs to change:

... lines 1 - 9
class CheeseListingOutput
{
... lines 12 - 39
public static function createFromEntity(CheeseListing $cheeseListing): self
{
$output = new CheeseListingOutput();
$output->title = $cheeseListing->getTitle();
$output->description = $cheeseListing->getDescription();
$output->price = $cheeseListing->getPrice();
$output->owner = $cheeseListing->getOwner();
$output->createdAt = $cheeseListing->getCreatedAt();
return $output;
}
... lines 51 - 72
}

Back in the transform, it's as simple as return CheeseListingOutput::createFromEntity($cheeseListing):

... lines 1 - 5
use App\Dto\CheeseListingOutput;
... lines 7 - 8
class CheeseListingOutputDataTransformer implements DataTransformerInterface
{
... lines 11 - 13
public function transform($cheeseListing, string $to, array $context = [])
{
return CheeseListingOutput::createFromEntity($cheeseListing);
}
... lines 18 - 22
}

Phew! It took some work, but this feels nice. I'm having a nice time.

Since we did just change a lot of stuff, let's run our tests to make sure we didn't break anything:

symfony php bin/phpunit

And... it always amazes me when I don't make any typos.

The one part of the DTO system that we haven't talked about yet is how validation happens. Do we validate the input object? Do we validate the entity object? How does that work? Let's make our validation rock-solid next.

Leave a comment!

6
Login or Register to join the conversation
Christin G. Avatar
Christin G. Avatar Christin G. | posted 10 months ago | edited

Hey there . What if I want to use a DTO input class for the owner entity also? I couldn't find any cast about embedded relations (like https://symfonycasts.com/sc... for DTO classes.

Reply

Hey @solverat!

I am... actually not sure :). Have you tried it yet and run into any issues? What exactly do you want to accomplish? The input/output DTO's are super interesting - but even the people at API Platform know they have some limitations.

Cheers!

Reply
Default user avatar
Default user avatar Tuan Vu | posted 1 year ago

Hi Ryan,

I don't think it is necessary to move the mapping logic to the DTO class. Isn't it a responsibility of the transformer and denormaliser?

Reply

I find this a bit unexpected as well. I thought DTOs were supposed to be super lightweight to the point of often not even having any methods at all, just public properties, but this video guides us in the opposite direction of making them a bit "fatter".

Furthermore, all original classes (two Transformers and a Denormalizer) are still kept for Api Platform to do it's magic – only transformation logic is moved. On one hand, them remaining there seems to defeat the purpose of moving the logic out of them. On the other hand though, it probably allows turning them into more generic services, not tied to specific Entity or DTO classes (I just remembered I have a project which does exactly that checked out on my disk).

One other thing I find somewhat quirky with these videos is Ryan's quite consistent use of pre-PHP7.4 syntax (e.g. no typed properties) in them, even though PHP 8.0 had already been released (or almost released) at the time they were filmed, and 7.4 had already been alive for a year. There are additional nuances you get to watch out for when using strict typing in your code. For example, $cheeseListing->setOwner($this->owner); in CheeseListingInput will cause an error if $owner is properly typehinted in both classes, but not passed in the input. Extra care is needed to avoid situations like this, which would most likely add even more "fat" to the DTOs.

Reply
Default user avatar
Default user avatar Tuan Vu | Tuan Vu | posted 1 year ago | edited

Tuan Vu Yes, I agree. Great response, thank you.

Reply

Hey Tuan Vu!

I'm happy either way :). I really think this comes down to a personal preference. Though if I *did* keep the logic in the transformer, etc, I would probably isolate it to a private function, just to keep things organized and readable. If you like that better, I think that is a great 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
    }
}