DTOs (Data Transfer Objects) are a special type of objects that are used for carrying over Data between processes. These not only holds the data but the data structure and the data types of each one of the elements of the object. You can use them as a contract between two parties to ensure data consistency and integrity.
If you are working with PHP Symfony Framework and implementing a REST API, you must use DTOs.
I implement DTOs in Symfony this way:
<?php
namespace App\UsersBundle\Api\Dtos;
use App\ApiBundle\Dtos\ApiResponsesInterface;
use DateTime;
use JMS\Serializer\Annotation as JMS;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Class BasicUser
*/
class BasicUser implements ApiResponsesInterface
{
/**
* @var string valid Email
* @Assert\NotNull
* @Assert\NotBlank(message="Email Address required")
* @Assert\Email(message="Invalid Email Address")
* @JMS\Type("string")
* @JMS\SerializedName("email")
*/
public $email;
/**
* @var string First Name
* @Assert\NotNull
* @Assert\NotBlank(message="First name cannot be blank")
* @Assert\Length(min="2", max="256")
* @JMS\Type("string")
* @JMS\SerializedName("firstName")
*/
public $firstName;
/**
* @var string Last Name
* @Assert\Length(min="2", max="256")
* @JMS\Type("string")
* @JMS\SerializedName("lastName")
*/
public $lastName;
/**
* @var string title
* @JMS\Type("string")
* @JMS\SerializedName("title")
*/
public $title;
/**
* @var bool Customer isActiveUser. Valid values are true, false
* @JMS\Type("boolean")
* @JMS\SerializedName("isActiveUser")
*/
public $isActiveUser;
/**
* @var string roleName
* @Assert\Length(min="2", max="256")
* @JMS\Type("string")
* @JMS\SerializedName("roleName")
*/
public $roleName;
/**
* @var string phone
* @JMS\Type("string")
* @JMS\SerializedName("phone")
*/
public $phone;
/**
* @var string extension
* @JMS\Type("string")
* @JMS\SerializedName("extension")
*/
public $extension;
/**
* @var bool isOnlySso
* @JMS\Type("boolean")
* @JMS\SerializedName("isOnlySso")
*/
public $isOnlySso;
...
Thanks to the power of Symfony Annotations and the PHP Reflection Class is very easy to work with DTOs.
In the Dto shown above, I'm using the JMS Serializer Package, that in my opinion works pretty good. You may want to use Symfony's own serializer available in recent versions of the Framework, if i remember well ,since Symfony 4.
Example of a typical use of a DTO:
1. Client send a REST Request to PHP Symfony Api Endpoint. If its a POST or a PUT it should contain a json payload in the body of the request:
{
"id": 810,
"email": "This email address is being protected from spambots. You need JavaScript enabled to view it.",
"password": null,
"firstName": "Fred",
"lastName": "Flinstone",
"title": null,
"isActiveUser": true,
"isConfirmed": true,
"isOnlySso": false,
...
"userDomainRoles": [
{
"domainId": 2,
"domainName": "COOL_APP_1",
"roles": [
{
"id": 59,
"name": "SYSTEM_ADMINISTRATOR"
}
]
},
{
"domainId": 4,
"domainName": "COOL_APP_2",
"roles": [
{
"id": 90,
"name": "SYSTEM_ADMINISTRATOR"
}
]
}
],
"phone": "",
"extension": ""
...
2. I implemented a Symfony Listener called param converter that is a very powerful tool if used properly. In all my Backend Mini Services1 working as REST APIs, im using DTOs and one single param converter in each mini service. This param converter works this way:
In the Controller Class, there is an action method for each endpoint. In the function arguments, instead of injecting the Request object, we inject the DTO Object. The way we tell Symfony we are using a DTO is type casting it:
/**
* @Route("/{page}/v1/user", options={"url_name":"users_crud", "menu_display_name": "Users", "menu_link": "", "menu_section":"Main", "is_user_navigation": false}, name="updateUserEndpoint", methods={"PUT"}, defaults={"_format": "json"}, requirements={"page"="(api|internal)"})
* @OA\Put(
* summary="Update a User ",
* description="Update a User providing all its id and fields that want to be updated.",
* operationId="updateUserEndpoint",
* @OA\Parameter(
* name="body",
* in="query",
* description="User object",
* required=true,
* @Model(type=DTO\User::class)
* ),
* @OA\Response(
* response=200,
* description="successful operation",
* @Model(type=ApiResponse::class)
* ),
* @OA\Response(
* response=412,
* description="Precondition Failed. Mostly due to mal formed json or data validation errors",
* @OA\JsonContent(
* type="string"
* )
* ),
* @OA\Response(
* response="500",
* description="There was an Internal Server Error."
* ),
* ),
* @Security(name="api_key")
* }
* )
* @param DTO\User $request
* @param UsersService $usersService
* @return JsonResponse
* @throws Exception
*/
public function updateUser(DTO\User $request, UsersService $usersService)
{
$result = $usersService->updateUser($request);
return new JsonResponse($result, $result->httpStatus ?? ErrorCodes::HTTP_200);
}
3. Then we have declared the param converter listener this way in services.yaml
:
api_request_param_converter:
class: App\ApiBundle\Utilities\ApiRequestParamConverter
tags:
- { name: request.param_converter }
This tells Symfony that for each Client Request, load the param converter:
<?php
namespace App\ApiBundle\Utilities;
use App\ApiBundle\Constants\AppConstants;
use App\ApiBundle\Exceptions\JsonException;
use App\ApiBundle\Exceptions\PreconditionFailedException;
use App\ApiBundle\Services\CustomValidationServiceProvider;
use JMS\Serializer\SerializerBuilder;
use JMS\Serializer\SerializerInterface;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\ParamConverter;
use Sensio\Bundle\FrameworkExtraBundle\Request\ParamConverter\ParamConverterInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Class ApiRequestParamConverter
*/
class ApiRequestParamConverter implements ParamConverterInterface
{
/*
* @var SerializerBuilder
*/
private SerializerInterface $serializer;
/*
* @var ValidatorInterface
*/
private ValidatorInterface $validator;
/*
* @var validationErrors
*/
private array $validationErrors;
/**
* @var CustomValidationServiceProvider
*/
private CustomValidationServiceProvider $customValidationServiceProvider;
/**
* ApiRequestParamConverter constructor.
* @param ValidatorInterface $validator
* @param CustomValidationServiceProvider $customValidationServiceProvider
*/
public function __construct(ValidatorInterface $validator, CustomValidationServiceProvider $customValidationServiceProvider)
{
$this->serializer = SerializerBuilder::create()->build();
$this->validator = $validator;
$this->customValidationServiceProvider = $customValidationServiceProvider;
$this->validationErrors = [];
}
/**
* Checks if the object is supported.
*
* @param ParamConverter $configuration Should be an instance of ParamConverter
*
* @return bool True if the object is supported, else false
*/
public function supports(ParamConverter $configuration)
{
$namespace = str_replace(AppConstants::APP_ROOT_FOR_BUNDLES, '', $configuration->getClass());
$bundleName = substr($namespace, 0, strpos($namespace, '\\', 0));
return strpos($namespace, $bundleName . AppConstants::API_REQUEST_DTOS_ROUTE) === 0;
}
/**
* Stores the object in the request.
*
* @param Request $request The request
* @param ParamConverter $configuration Contains the name, class and options of the object
*
* @return void True if the object has been successfully set, else false
*/
public function apply(Request $request, ParamConverter $configuration)
{
$json = $request->getContent();
$class = $configuration->getClass();
//Try to deserialize the request dto
try {
$dto = $this->serializer->deserialize($json, $class, AppConstants::API_SERIALIZE_FORMAT);
} catch (\Throwable $e) {
$jsonMessage = $this->customValidationServiceProvider->getJsonErrorMessage(json_last_error());
throw new JsonException($e->getMessage() . ' more info:' . $jsonMessage);
}
//If there are validation errors, throw validation exception
$errors = $this->validator->validate($dto);
if (!empty($errors)) {
foreach ($errors as $error) {
$this->validationErrors[] = array('field' => $error->getPropertyPath(), ' message' => $error->getMessage());
}
if (!empty($this->validationErrors)) {
throw new PreconditionFailedException($this->serializer->serialize($this->validationErrors, AppConstants::API_SERIALIZE_FORMAT));
}
}
$request->attributes->set($configuration->getName(), $dto);
}
}
If the Object injected as argument in the endpoint action method is supported, then the magic happens:
Param converter will apply the logic to the request object, in this case:
- Try to deserialize json into DTO class. The logic in the JMS Serializer package will make sure that the structure of the json is fully compatible with the structure declared in the DTO class. This is very useful when dealing with complex data structures and its the way to ensure data consistency.
- After the json payload was successfully deserialized into its corresponding DTO class, I ask Symfony validator to validate the dto object, checking each one of the constrains declared as Assert annotation in the DTO Class. If there are any validation errors, a 403 precondition exception will be returned to the client telling what validation failed.
- If everything is ok, then in the endpoint action method a dto object containing all data in its corresponding structure is available to be passed for further processing. in this case this is an update user action, then I call the updateUser method in the Users Service:
/**
* @param DTO\User $userDto
* @return ApiResponse
* @throws Exception
*/
public function updateUser(DTO\User $userDto): ApiResponse
{
$this->em->getConnection()->beginTransaction();// suspend auto-commit and start transaction
try {
$userForUpdate = $this->em->getRepository(User::class)->find($userDto->id);
if ($userForUpdate) {
$updateUserTransformer = new UpdateUserTransformer($userDto, $userForUpdate, $this->em, $this->customValidation, $this->passwordEncoder);
$user = $updateUserTransformer->transform();
.....
In the Transformer class I'm also doing some custom validation like checking duplicated Email and then transforming the Dto user object into a Doctrine User Entity, to be persisted in Database:
...
/**
* CreateCustomerTransformer constructor.
* @param DTO\User $users
* @param User $user
* @param EntityManagerInterface $em
* @param CustomValidationServiceProvider $customValidation
*/
public function __construct(
DTO\User $users,
User $user,
EntityManagerInterface $em,
CustomValidationServiceProvider $customValidation
)
{
$this->apiUsers = $users;
$this->user = $user;
$this->em = $em;
$this->customValidation = $customValidation;
}
/**
* @return User
* @throws CustomValidationException
* @throws Exception
*/
public function transform(): User
{
if ($this->validate()) {
//Transform Users
$this->user->setEmail($this->apiUsers->email);
$this->user->setFirstName($this->apiUsers->firstName);
$this->user->setLastName($this->apiUsers->lastName);
$this->user->setTitle($this->apiUsers->title ?? '');
$this->user->setPhone($this->apiUsers->phone);
$this->user->setExtension($this->apiUsers->extension);
$this->user->setUpdatedAt(new DateTime('now'));
$this->user->setIsActiveUser($this->apiUsers->isActiveUser);
$this->user->setIsConfirmed($this->apiUsers->isConfirmed);
$this->user->setIsOnlySso($this->apiUsers->isOnlySso ?? false);
...
If you use Nelmio Api Doc Package and Zircote Swagger, you can display the Open Api Specification for your REST API Endpoints inlcuding the models taken from the DTOs:
Thanks to the DTO Classes we can ensure data consistency and integrity for every single request and the param converter listener is implemented in such a way that all endpoints injecting a DTO Class will be deserialized and validated. This is saving tons of hours of work on implementing complicated and messy validations for each payload in each endpoint.
And you can use DTOs for response objects too. This way, any developer implementing a client interface, only need to go look at the Swagger definition of the endpoint and very easily get the data structure and data types of the json returned.
When you are building the response object, you use the DTO class to serialize the response object, thus applying all validation required and ensuring that each time a valid object of the DTO Class is returned.
Once again we just found out that developing with PHP Symfony and fully Object Oriented Programming best practices will reduce considerably development time for the creation of Enterprise Class Software Applications.
Next Up:
How to validate input data in Symfony using DTOs
1 Mini Services Architecture Part 1