Convert dynamically Request content to DTO with Symfony

Etearner
9 min readMay 13, 2023

--

Hi guys, after a long period of reflection, I have decided to write my first blog article about programming. I have been considering the idea to share my experiences with the community for a while, but it took me time to decide how and where to start. Now I think, time has come.😅

TL DR.

In this article, we will discuss Data Transfer Objects (DTO) in Symfony and primarily focus on how to dynamically convert the content of a client request into an input DTO.

What is a DTO?

DTO is an accronym for Data Transfer Object, a design pattern that purpose is to simplify the transfer of data between subsystems of a software application. It’s commonly used to facilitate communication between two systems (like an API and the server) without potentially exposing sensitive informations.

Objects created according to this design pattern are called by the eponymous name.

The difference between data transfer objects and business objects or entities is that a DTO does not have any behavior except for storage, retrieval, serialization and deserialization of its own data. They should not contain any business logic!

As things are explained now, we can work.

Let’s consider an API endpoint for updating a Book where we need to provide the book’s title, price, and year of release.

In our application, we will define the following class as the DTO and expect the client to send data according to our requirements.

# This is the API endpoint
PUT /api/books/{id}
# Here is the DTO.
<?php

namespace App\Dto\Book\Input;

final class UpdateBookInput
{
public string $title;

public float $price;

public int $year;
}

This DTO exposes the attributes that the client must set to update a Book.

Now let’s assume that the client will request the endpoint with the following data:

{
"title": "The magma kid",
"price": 28.05,
"year": 1986
}

With Symfony, thanks to the HttpFoundation component, this data will be available inside the Request object.

Now, the question is: How do we map the client request content to our DTO? Or in a more general sense, how do we convert the Request content to a DTO?

This article will address that question and provide step-by-step explanations, starting from the least clean approach and gradually moving towards best practices.

Approach 1: Creating an instance of the DTO in the controller

When faced with this situation, the first thing that comes to mind is to instantiate the DTO and fill its data in the controller.

<?php

namespace App\Controller;

use App\Dto\Book\Input\UpdateBookInput;
use App\Entity\Book;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\AsController;

#[AsController]
final class UpdateBookController
{
public function __invoke(Request $request): Book
{
# We retrieve user data here, and convert it to array first.
$content = json_decode($request->getContent(), true);

# Then we construct the DTO and set data.
$input = new UpdateBookInput();
$input->title = $content['title'];
$input->price = $content['price'];
$input->year = $content['year'];

#...
}
}

This approach will work, but it may become less smooth when dealing with a DTO that has many attributes.

If we have numerous attributes in our DTO, we would have to set each attribute in the controller. This can be cumbersome, difficult to maintain, and not very elegant. Code should also be aesthetically pleasing.

Approach 2: Creating an instance of the DTO in the controller with a hydrate method

To avoid the issue mentioned above, we can create a method that will hydrate our DTO by iterating over the content array.

<?php

namespace App\Controller;

use App\Dto\Book\Input\UpdateBookInput;
use App\Entity\Book;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\AsController;

#[AsController]
final class UpdateBookController
{
public function __invoke(Request $request): Book
{
$input = $this->hydrate(
json_decode($request->getContent(), true),
new UpdateBookInput()
);
#...
}

private function hydrate(array $content, UpdateBookInput $input): UpdateBookInput
{
foreach($content as $key => $value) {
if (property_exists($input, $key)) {
$input->$key = $value;
}
}

return $input;
}
}

This approach reduces the repetition of setting each attribute manually and allows for a more dynamic way of populating the DTO.

However, it still requires us to rewrite the hydrate method for each controller. This is annoying as it conduct us to duplicate code. And we can’t just do that.

Approach 3: Using a mapper through a trait or an helper service

Since it’s not a good practice to duplicate code, there are many possibilities to solve this problem. We’ll see you two of them, that seems really good in my opinion.

  • Using an helper service that will manage this transformation. Then we’ll just need to call it whenever we want.
# We create the helper service here.
<?php

namespace App\Helper;

abstract class ObjectHydrator
{
public static function hydrate(array $content, object $input): void
{
foreach ($values as $key => $value) {
if (property_exists($dto, $key)) {
$dto->$key = $value;
}
}
return $input;
}
}

No change is needed inside the DTO class. Let’s see how look the controller now.

<?php

namespace App\Controller;

use App\Dto\Book\Input\UpdateBookInput;
use App\Entity\Book;
use App\Helper\ObjectHydrator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\AsController;

#[AsController]
final class UpdateBookController
{
public function __invoke(Request $request): Book
{
$input = ObjectHydrator::hydrate(
json_decode($request->getContent(), true),
new UpdateBookInput()
)
#...
}
}

That’s it.

From now, in each controller we will just need to call the helper, and the job will be done. Now let’s see the second option.

  • Using a trait that will provide a static method, that can be used to hydrate a DTO from an array of value.
# Here is the Trait.
<?php

namespace App\Helper\Traits;

trait HydrateStaticTrait
{
public static function hydrate(array $values): self
{
$dto = new self();

foreach ($values as $key => $value) {
if (property_exists($dto, $key)) {
$dto->$key = $value;
}
}

return $dto;
}
}
# Now our DTO, updated with usage of that Trait.
<?php

namespace App\Dto\Book\Input;

use App\Helper\Traits\HydrateStaticTrait;

final class UpdateBookInput
{
use HydrateStaticTrait;

public string $title;

public float $price;

public int $year;
}
# And finally our updated controller.
<?php

namespace App\Controller;

use App\Dto\Book\Input\UpdateBookInput;
use App\Entity\Book;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\AsController;

#[AsController]
final class UpdateBookController
{
public function __invoke(Request $request): Book
{
$input = UpdateBookInput::hydrate(json_decode($request->getContent(), true))
#...
}
}

Is this not incredible ? One line and our DTO is fully mapped with the client request content.

Oh my God, by Dwight Schrute
Dwight Schrute is impressed.

Both approaches are good as they effectively eliminate code duplication and provide a more modular and reusable solution.

⚠️However, if the use of a static method (hydrate) gives a nice result, it is important to note that in the scenario where hydration will require a service to help resolve the values, the use of a static method will no longer be suitable. Please consider this!

Approach 4 : Using a ValueResolver

What about serialization ?

Assuming that we want only users that have role admin to update a Book price, of course we will not create an API endpoint for admins and one for non-admin users, but will use serialization.

Let’s apply some changes in our DTO.

<?php

namespace App\Dto\Book\Input;

use Symfony\Component\Serializer\Annotation\Groups;

final class UpdateBookInput
{
#[Groups(['user:edit', 'user:edit:admin'])]
public string $title;

#[Groups(['user:edit:admin'])]
public float $price;

#[Groups(['user:edit', 'user:edit:admin'])]
public int $year;
}

What’s changed?

We added serialization groups for each attributes, as we can see, the price attribute, has a single group user:edit:admin.

With these changes, it gonna be a hard to fill or DTOs through our current mappers (using trait or helper). We should find a way to get attributes groups for each DTO, then current request context and write a process that will do to the job.

We can find a way do it, for sure. But before trying to do a such as complicated things: isn’t there a way to do it more simply?

💭 Don’t forget that we are using Symfony, and as far I know, the community thought a lot of cases.

Khaby Lame consterned

The answer is: OF COURSE, YES! There is a way to do it efficiently, cleanly and simply.

If you are using ApiPlatform, maybe you never experienced this situation. Thank to its own serializer, this transformation is made automatically when you define an input in entity route configuration. The client request content are “automatically” transformed as an instance of your input DTO, by ApiPlatform.

Probably by using the solution we will see below.

To simplify what we’re trying to achieve since the beginning of this article including a serialization context, we can combine (thank to the HttpKernel component) usage of a ValueResolver with Symfony SerializerInterface to “resolve” the value of each DTO arguments, according to client request.

How it works?

First of all, let’s create an interface that will be implemented by each DTO we want to be deserialized on each request.

<?php

namespace App\Dto\Input;

interface InputInterface
{
}

That’s it.

Now, let’s implement it to our input DTO.

<?php

namespace App\Dto\Book\Input;

use App\Dto\Input\InputInterface;
use Symfony\Component\Serializer\Annotation\Groups;

final class UpdateBookInput implements InputInterface
{
#...

Then we can create our ValueResolver.

<?php

namespace Infra\ValueResolver;

use App\Dto\Input\InputInterface;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\Serializer\SerializerInterface;

final class InputValueResolver implements ValueResolverInterface
{
public function __construct(
private readonly SerializerInterface $serializer,
) {
}

public function resolve(Request $request, ArgumentMetadata $argument): array
{
$argumentType = $argument->getType();

if (!$argumentType || !is_subclass_of($argumentType, InputInterface::class)) {
return [];
}

return [
$this->serializer->deserialize(
$request->getContent(),
$argument->getType(),
'json',
$request->getContext()
),
];
}
}

We are almost done.

⚠️ We are using json as data format in this example, you should adapt your code, if it’s not the case.

Now, let’s edit our controller, to make it cleaner according the treatment made by the InputValueResolver.

<?php

namespace App\Controller;

use App\Dto\Book\Input\UpdateBookInput;
use App\Helper\ObjectHydrator;
use App\Entity\Book;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\AsController;

#[AsController]
final class UpdateBookController
{
public function __invoke(UpdateBookInput $input): Book
{
#...
}
}

And that’s it. The job is done.

From now, in each controller, you just need to inject the DTO in the main method and it will be “automatically” hydrated. Isn’t amazing?

I’m amazed. I promise.
Jim Halpert can’t believe it.

Conclusion

In this article, we explored different approaches for converting request content into a DTO in Symfony. We started with a basic approach of instantiating the DTO in the controller and manually setting its attributes. Then, we improved the code by introducing a hydrate method throug a trait or helper, to dynamically populate the DTO. Finally, we introduced a ValueResolver to handle the conversion responsibility by considering Request context, and make the code more modular.

The choice of approach depends on the specific needs and constraints of the project. Consider factors such as project size, complexity, performance requirements, and development timeline when selecting the most suitable approach.

I hope you found this article helpful and that it provided some insights into working with DTOs in Symfony.

Feel free to leave your feedback or questions in the comments section below.

Happy coding! 😌

To go further

  • HydrateStaticTrait or ObjectHydrator remains relevant as in some situations, you will not deal with serialization.
  • The method hydrate (of trait or helper), can be improved by using the PropertyAccess component instead of property_exists, to set values.
  • If in your project, you have a custom serializer that update your Request context, for some reason you’ll not get that update in the ValueResolver. The way to achieve it, is to create a Request Data holder with an attribute context that will be updated in your serializer, then you can use it in your ValueResolver. I can provide an example if needed.
  • The code displayed in this post has been tested on Symfony 6.2 and ApiPlatform 2.7/, with Php 8.1/8.2.
  • Since the release of Symfony’s latest version (6.3) a month ago, there is now an alternative to using ValueResolver. This alternative involves the usage of new attributes to map requests to typed objects and validate them. Make sure to check it in official documentation.

--

--

Etearner

An eternal learner who is passionate about his craft and helping others advance in theirs.