An easy way to validate DTO's using Symfony attributes

Nacho Colomina Torregrosa - Sep 20 - - Dev Community

Introduction

DTOs are simple objects that encapsulate data attributes without containing any business logic. They are often used to aggregate data from multiple sources into a single object, making it easier to manage and transmit. By using DTOs, developers can reduce the number of method calls, improve performance, and simplify data handling, especially in distributed systems or APIs.

As an example, we can use DTO's to map the data received via an HTTP Request. Those DTO's would hold into their properties the received payload values and we could use them within the application, for instance, by creating a doctrine entity object ready to be persisted to the database from the data holded in the DTO. As the DTO data would already be validated, it can reduce the probability of generating errors during the database persisting.

The MapQueryString and MapRequestPayload attributes

The MapQueryString and the MapRequestPayload attributes allows us to map the received query string and payload parameters respectively. Let's see an example of both of them.

A MapQueryString example

Let's imagine we have a Symfony route that can receive the following parameters within the query string:

  • from: A mandatory from date
  • to: A mandatory to date
  • age: An optional age

Based on the above parameters, we want to map them to the following dto:

readonly class QueryInputDto {
   public function __construct(
      #[Assert\Datetime(message: 'From must be a valid datetime')]
      #[Assert\NotBlank(message: 'From date cannot be empty')]
      public string $from,
      #[Assert\Datetime(message: 'To must be a valid datetime')]
      #[Assert\NotBlank(message: 'To date cannot be empty')]
      public string $to,
      public ?int $age = null 
   ){}
}
Enter fullscreen mode Exit fullscreen mode

To map them, we only have to use the MapQueryString attribute following this way:

#[Route('/my-get-route', name: 'my_route_name', methods: ['GET'])]
public function myAction(#[MapQueryString] QueryInputDTO $queryInputDto) 
{
   // your code
}
Enter fullscreen mode Exit fullscreen mode

As you can see, when symfony detects that the argument $queryInputDto has been flagged with the #[MapQueryString] attribute, it automatically maps the query string received parameters into that argument which is an instance of the QueryInputDTO class.

A MapRequestPayload example

In this case, let's imagine we have a Symfony route which receives the required data to register a new user within the JSON request payload. Those parameters are the following:

  • name: mandatory
  • email: mandatory
  • birth date (dob): mandatory

Based on the above parameters, we want to map them to the following dto:

readonly class PayloadInputDto {
    public function __construct(
       #[Assert\NotBlank(message: 'Name cannot be empty')] 
       public string $name,
       #[Assert\NotBlank(message: 'Email cannot be empty')]
       #[Assert\Email(message: 'Email must be a valid email')]
       public string $email,
       #[Assert\NotBlank(message: 'From date cannot be empty')]
       #[Assert\Date(message: 'Dob must be a valid date')]
       public ?string $dob = null 
    ){}
 }
Enter fullscreen mode Exit fullscreen mode

To map them, we only have to use the MapRequestPayload attribute following this way:

#[Route('/my-post-route', name: 'my_post_route', methods: ['POST'])]
public function myAction(#[MapRequestPayload] PayloadInputDTO $payloadInputDto) 
{
   // your code
}
Enter fullscreen mode Exit fullscreen mode

As we've seen in the previous section, when symfony detects that the argument $payloadInputDto has been flagged with the #[MapRequestPayload] attribute, it automatically maps the payload received parameters into that argument which is an instance of the PayloadInputDTO class.

MapRequestPayload works both for JSON payloads and form-url-encoded payloads.

Handling DTO validation errors

If the validation fails during the mapping process (for instance, the mandatory email has not been sent) Symfony throws a 422 Unprocessable Content exception. If you want to catch these kind of exceptions and return the validation errors as, for instance, json to the client, you can create an event subscriber and keep listening to the KernelException event.

class KernelSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::EXCEPTION => 'onException'
        ];
    }

    public function onException(ExceptionEvent $event): void
    {
        $exception = $event->getThrowable();
        if($exception instanceof ValidationFailedException || $exception->getPrevious() instanceof ValidationFailedException) {
            $validationFailedException = ($exception instanceof ValidationFailedException)
                ? $exception
                : $exception->getPrevious()
            ;

            $errors = [];
            foreach($validationFailedException->getViolations() as $violation) {
                $errors[] = [
                    'path' => $violation->getPropertyPath(),
                    'error' => $violation->getMessage() 
                ];
            }

            $event->setResponse(new JsonResponse($errors, 400));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

After the onException method is triggered, it checks if the event exception is an instance of the UnprocessableEntityHttpException. If so, it also checks if the unprocessable error comes from a failed validation checking whether the previous exception is an instance of the ValidationFailedException class. If so, it stores all the violation errors in an array (only the property path as key and the violation message as error), creates a JSON response from these errors and sets the new response to the event.

The following image shows the JSON response for a request which fails since the email has not been sent:

@baseUrl = http://127.0.0.1:8000

POST {{baseUrl}}/my-post-route
Content-Type: application/json

{
    "name" : "Peter Parker",
    "email" : "",
    "dob" : "1990-06-28"
}

-------------------------------------------------------------
HTTP/1.1 422 Unprocessable Entity
Cache-Control: no-cache, private
Content-Type: application/json
Date: Fri, 20 Sep 2024 16:44:20 GMT
Host: 127.0.0.1:8000
X-Powered-By: PHP/8.2.23
X-Robots-Tag: noindex
Transfer-Encoding: chunked

[
  {
    "path": "email",
    "error": "Email cannot be empty"
  }
]
Enter fullscreen mode Exit fullscreen mode

The above image request has been generated using http files.

Creating your custom resolver

Let's imagine we have some routes which receive the query string parameters into an array named "f". Something like this:

/my-get-route?f[from]=2024-08-20 16:24:08&f[to]=&f[age]=14
Enter fullscreen mode Exit fullscreen mode

We could create a custom resolver to check for that array in the request and then validate the data. Let's code it.

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\HttpKernel\Controller\ValueResolverInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validator\ValidatorInterface;

class CustomQsValueResolver implements ValueResolverInterface, EventSubscriberInterface
{
    public function __construct(
        private readonly ValidatorInterface $validator,
        private readonly SerializerInterface $serializer
    ){}

    public static function getSubscribedEvents(): array
    {
        return [
            KernelEvents::CONTROLLER_ARGUMENTS => 'onKernelControllerArguments',
        ];
    }

    public function resolve(Request $request, ArgumentMetadata $argument): iterable
    {
        $attribute = $argument->getAttributesOfType(MapQueryString::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null;

        if (!$attribute) {
            return [];
        }

        if ($argument->isVariadic()) {
            throw new \LogicException(sprintf('Mapping variadic argument "$%s" is not supported.', $argument->getName()));
        }

        $attribute->metadata = $argument;
        return [$attribute];
    }

    public function onKernelControllerArguments(ControllerArgumentsEvent $event): void
    {
        $arguments = $event->getArguments();
        $request  = $event->getRequest();

        foreach ($arguments as $i => $argument) {
            if($argument instanceof MapQueryString ) {
                $qs = $request->get('f', []);
                if(count($qs) > 0) {
                    $object = $this->serializer->denormalize($qs, $argument->metadata->getType());
                    $violations = $this->validator->validate($object);
                    if($violations->count() > 0) {
                        $validationFailedException = new ValidationFailedException(null, $violations);
                        throw new UnprocessableEntityHttpException('Unale to process received data', $validationFailedException);
                    }

                    $arguments[$i] = $object;
                }
            }

        }

        $event->setArguments($arguments);
    }
}
Enter fullscreen mode Exit fullscreen mode

The CustomQsValueResolver implements the ValueResolverInterface and the EventSubscriberInterface, allowing it to resolve arguments for controller actions and listen to specific events in the Symfony event system. In this case, the resolver listens to the Kernel CONTROLLER_ARGUMENTS event.
Let's analyze it step by step:

The constructor

The constructor takes two dependencies: The Validator service for validating objects and a the Serializer service for denormalizing data into objects.

The getSubscribedEvents method

The getSubscribedEvents method returns an array mapping the KernelEvents::CONTROLLER_ARGUMENTS symfony event to the onKernelControllerArguments method. This means that when the CONTROLLER_ARGUMENTS event is triggered (always a controller is reached), the onKernelControllerArguments method will be called.

The resolve method

The resolve method is responsible for resolving the value of an argument based on the request and its metadata.

  • It checks if the argument has the MapQueryString attribute. If not, it returns an empty array.
  • If the argument is variadic, that is, it can accept a variable number of arguments, it throws a LogicException, indicating that mapping variadic arguments is not supported.
  • If the attribute is found, it sets the metadata property of the attribute and returns it as a php iterable.

The onKernelControllerArguments method

The onKernelControllerArguments method is called when the CONTROLLER_ARGUMENTS event is triggered.

  • It retrieves the current arguments and the request from the event.
  • It iterates over the arguments, checking for arguments flagged as MapQueryString
  • If found, it retrieves the query string parameters holded by the "f" array using $request->get('f', []).
  • If there are parameters, it denormalizes them into an object of the type specified in the argument's metadata (The Dto class).
  • It then validates the object using the validator. If there are validation violations, it throws an UnprocessableEntityHttpException which wraps a ValidationFailedException with the validation errors.
  • If validation passes, it replaces the original argument with the newly created object.

Using the resolver in the controller

To instruct the MapQueryString attribute to use our recently created resolver instead of the default one, we must specify it with the attribute resolver value. Let's see how to do it:

#[Route('/my-get-route', name: 'my_route_name', methods: ['GET'])]
public function myAction(#[MapQueryString(resolver: CustomQsValueResolver::class)] QueryInputDTO $queryInputDto) 
{
   // your code
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

In this article, we have analized how symfony makes our lives easier by making common application tasks very simple, such as receiving and validating data from an API. To do that, it offers us the MapQueryString and MapRequestPayload attributes. In addition, it also offers us the possibility of creating our custom mapping resolvers for cases that require specific needs.

If you like my content and enjoy reading it and you are interested in learning more about PHP, you can read my ebook about how to create an operation-oriented API using PHP and the Symfony Framework. You can find it here: Building an Operation-Oriented Api using PHP and the Symfony Framework: A step-by-step guide

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player