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!

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