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 the MapRequestPayload attributes allows us to map the received query string and payload parameters respectively. Let's see an example of both of them.
Let's imagine we have a Symfony route that can receive the following parameters within the query string:
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 ){} }
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 }
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.
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:
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 ){} }
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 }
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.
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 UnprocessableEntityHttpException) { $previous = $exception->getPrevious(); if($previous instanceof ValidationFailedException) { $errors = []; foreach($previous->getViolations() as $violation) { $errors[] = [ 'path' => $violation->getPropertyPath(), 'error' => $violation->getMessage() ]; } $event->setResponse(new JsonResponse($errors)); } } } }
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" } ]
The above image request has been generated using http files.
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
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); } }
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 takes two dependencies: The Validator service for validating objects and a the Serializer service for denormalizing data into objects.
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 is responsible for resolving the value of an argument based on the request and its metadata.
The onKernelControllerArguments method is called when the CONTROLLER_ARGUMENTS event is triggered.
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 }
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
The above is the detailed content of An easy way to validate DTOs using Symfony attributes. For more information, please follow other related articles on the PHP Chinese website!