Convert Request Data to DTO Input Objects With Symfony

Mokhtar Tlili on 2022-11-16

One of the most recent design patterns is DTOs (Data Transfer Objects) where these objects can be an envelope that wraps what clients send in the request (inputs) and what clients want in the response (outputs).

Today we are going to talk about good practices for using DTOs in Symfony application.

Let’s assume that we have an application that provides an API endpoint to create a new user or any kind of resource e.g:

Request:

POST /api/users HTTP/2.0
{
  "first_name": "Foo",
  "last_name": "Bar",
  "email": "foo.bar@test.ts"
}

Basically when our API service receive this data is going to perform some business logic: 1. Decoding 2. Validating 3. And more (maybe storing and/or sending data to third party services…)

<?php

namespace App\Controller;

use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Validator\Constraints as Assert;

class UserController
{
    public function __construct(private ValidatorInterface $validator)
    {
    }
    
    #[Route(path: '/users', methods: ['post'])]
    public function __invoke(Request $request): Response
    {
        $input = json_decode($request->getContent(), true);

        $constraint = new Assert\Collection([
            # the keys correspond to the keys in the input array
            'first_name' => new Assert\NotBlank(),
            'last_name' => new Assert\NotBlank(),
            'email' => new Assert\Email(),
        ]);

        # initialize some elements in case null or using custom initializer
        $input['first_name'] ??= 'foo';
        $input['last_name'] ??= 'bar';

        $errors = $this->validator->validate($input, $constraint);

        # more business logic 
    }
}

There are common things in every single call of any API endpoint that accepts data which are decoding and validating.

Thanks to Serializer and Validator Symfony components for having features to deserialize (decode + denormalize) and validate data.

But do we really need to denormalize decoded data since we need only the decoded one?!

In fact, it’s a good question but dealing with decoded data that might be an array or a generic stdClass object leads to complexity and annoying code: - Initialization default values - Hard-coded array indexes - Not readable and hard to maintain - And more …

And here is the importance of DTO. It comes to resolving all these issues by deserializing data to an input object and then validating the input using constraints validations of the Validator component and/or custom validators in the input object itself.

so let’s start by creating a new DTO Input:

<?php

namespace App\Dto\Input;

class UserInput
{
    #[Assert\NotBlank]
    private $firstName = 'foo';
    #[Assert\NotBlank]
    private $lastName = 'bar';
    #[Assert\Email]
    private $email;
    
    # getters and setters or make properties public
}

Next is to use our input class and do some refactoring in our UserController:

<?php

namespace App\Controller;

use App\Dto\UserInput;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

class UserController
{
    public function __construct(
        private SerializerInterface $serializer,
        private ValidatorInterface $validator
    ) {
    }
    
    #[Route(path: '/users', methods: ['post'])]
    public function __invoke(Request $request): Response
    {
        # convert request json data to dto input object
        $input = $this->serializer->deserialize($request->getContent(), UserInpout::class, 'json');
        
        # validate input object based on constraints that lives in input class itself
        $errors = $this->validator->validate($input);

        # more business logic 
    }
}

That’s it…done!. Our data are wrapped into DTO input and it’s clearly more flexible and readable compared to arrays or generic objects, isn’t it?

But… do we really need to inject Serializer and Validator on each API controller action?

A short answer is NOOO! we can do more improvements by using Controller Argument Value Resolver which will resolve the value of the input action argument. Thanks to `HttpKernel` component.

Create an argument value resolver service:

<?php

namespace App\ArgumentResolver;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;

class InputArgumentValueResolver implements ArgumentValueResolverInterface
{
    public function __construct(
        private SerializerInterface $serializer,
        private ValidatorInterface $validator
    ) {
    }

    public function supports(Request $request, ArgumentMetadata $argument): bool
    {
        return UserInput::class === $argument->getType();
    }

    public function resolve(Request $request, ArgumentMetadata $argument): iterable
    {
        yield $this->createFromRequest($request, $argument->getType());
    }
    
    private function createFromRequest(Request $request, ArgumentMetadata $argument): UserInput
    {
        $input = $this->serializer->deserialize($request->getContent(), $argument->getType(), 'json');
        
        $errors = $this->validator->validate($input);
        
        return $input;
    }
}

I guess the code explain him self so any argument type-hinted by UserInput in controllers actions our InputArgumentValueResolver will resolve its value.

Now we can simplify our controller to be like:

<?php

namespace App\Controller;

use App\Dto\UserInput;
use Symfony\Component\Routing\Annotation\Route;

class UserController
{    
    #[Route(path: '/users', methods: ['post'])]
    public function __invoke(UserInput $input): Response
    {
        # business logic 
    }
}

But our code looks is missing something whenever we create a new DTO input we need to adjust the resolver to support the new input class. Well that’s right let’s improve our resolver to support any input it’s a good place to use DIP (Dependency Inversion Principle) since our resolver support method depends on argument type.

Create an interface and implement UserInput it to:

<?php

namespace App\Dto\Input;

interface InputInterface
{
}
<?php

namespace App\Dto\Input;

class UserInput implements InputInterface
...

Then adjust few lines in the resolver :

<?php

namespace App\ArgumentResolver;

use App\Dto\Input\InputInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface;

class InputArgumentValueResolver implements ArgumentValueResolverInterface
{
    public function __construct(
        private SerializerInterface $serializer,
        private ValidatorInterface $validator
    ) {
    }

    public function supports(Request $request, ArgumentMetadata $argument): bool
    {
        return is_subclass_of($argument->getType(), InputInterface::class);
    }

    public function resolve(Request $request, ArgumentMetadata $argument): iterable
    {
        yield $this->createFromRequest($request, $argument->getType());
    }
    
    private function createFromRequest(Request $request, ArgumentMetadata $argument): InputInterface
    {
        $input = $this->serializer->deserialize($request->getContent(), $argument->getType(), 'json');
        
        $errors = $this->validator->validate($input);
        
        return $input;
    }
}

Done!

From now on any input implements InputInterface can be used as an argument in the controller action.

Notes: For sure there are more to do like: - Catching deserialize exceptions - Throwing validation errors. - Supporting validations groups - Supporting desrialize context

By the way, during my spare time, I made an open-source Symfony bundle that converts request data to DTO input objects. The bundle supports multi-type of data json, xml and form and has more features like validations, global YAML config for all inputs and custom config via PHP 8 Attributes (context + groups).

Feel free to read the doc and give it a try if you like it.

https://github.com/sfmok/request-input-bundle

Cheers !!