DTOs (Data Transfer Objects) are very useful for handling complex data structures. In PHP Symfony is very easy to deserialize and validate complex deep nested json or xml strings passed in the body of an Api Http Request.

Prerequisites:

Step 1. Make sure you have set the Request Param Converter as explained in the articles above and enabled it as an event listener:

#config/services.yaml

services:
    _defaults:
        autowire: true
        autoconfigure: true

    ###>>> Use only Services in the Services folder of all bundles living in /src
    App\:
        resource: '../src/*'
        exclude: '../src/{Exceptions,Constants,Dtos,Transformers,Utilities,DependencyInjection,Entity,Migrations,Tests,Kernel.php}'
    ###<<<

    ###>>> API Bundle
    api_request_param_converter:
        class: App\ApiModule\Utilities\ApiRequestParamConverter
        tags:
            - { name: request.param_converter }
        arguments:
            - '@validator'
namespace App\ApiModule\Utilities;

use App\ApiModule\Constants\AppConstants;
use App\ApiModule\Exceptions\JsonException;
use App\ApiModule\Exceptions\PreconditionFailedException;
use App\ApiModule\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);
    }
}

 

Step 2. In the Method used to process the api request, do something like this:

#ApiModule/Controller/CustomersController.php

namespace App\ApiModule\Controller;

use App\ApiModule\Constants\ErrorCodes;
use App\ApiModule\Dtos as DTO;
use App\ApiModule\Services\CustomersService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Nelmio\ApiDocBundle\Annotation\Model;
use Nelmio\ApiDocBundle\Annotation\Security;
use OpenApi\Annotations as OA;

/**
 * Class CustomersController
 * @OA\Tag(name="Customers")
 */
class CustomersController extends AbstractController
{
   //...
    
    /**
     * @Route("/api/v1/customer", name="updateCustomerEndpoint", methods={"PUT"}, defaults={"_format": "json"})
     * @param DTO\Customer $request
     * @param CustomersService $customersService
     * @return JsonResponse
     */
    public function updateCustomerAction(DTO\Customer $request, CustomersService $customersService)
    {
        $customer = $customersService->upsertCustomer($request);
        $result = $customersService->getCustomerById($customer->getId());
        
        return new JsonResponse($result, ErrorCodes::HTTP_200);
    }
//...

When you type hint the DTO Class to the $request argument, Symfony's event handler triggers the logic in the param converter, validates the existence of the DTO\Customer class and then process the logic in the apply method, where the de-serialization occurs in this case using JMS Serializer.

#ApiModule/Utilities/ApiRequestParamConverter.php
//...

 $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);
        }

//...

In this case, the format is json.

The Customer DTO has this structure:

#ApiModule/Dtos/Customer.php

namespace App\ApiModule\Dtos;

use JMS\Serializer\Annotation as JMS;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Class Customer
 */
class Customer implements ApiResponsesInterface
{
    /**
     * @var integer Id
     * @JMS\Type("integer")
     * @JMS\SerializedName("id")
     */
    public $id;
    
    /**
     * @var string First Name
     * @Assert\NotNull
     * @Assert\NotBlank(message="First name cannot be blank")
     * @Assert\Length(min="2", max="50")
     * @JMS\Type("string")
     * @JMS\SerializedName("firstName")
     */
    public $firstName;
    
    /**
     * @var string Last Name
     * @Assert\NotNull
     * @Assert\NotBlank(message="Last name cannot be blank")
     * @Assert\Length(min="2", max="50")
     * @JMS\Type("string")
     * @JMS\SerializedName("lastName")
     */
    public $lastName;
    
    /**
     * @var string primaryEmail
     * @Assert\NotNull
     * @Assert\NotBlank(message="Email cannot be blank")
     * @Assert\Length(min="2", max="50")
     * @JMS\Type("string")
     * @JMS\SerializedName("primaryEmail")
     */
    public $primaryEmail;
    
    /**
     * @var string phoneNumber
     * @Assert\NotNull
     * @Assert\NotBlank(message="Phone Number cannot be blank")
     * @Assert\Length(min="2", max="50")
     * @JMS\Type("string")
     * @JMS\SerializedName("phoneNumber")
     */
    public $phoneNumber;
    
    /**
     * @var string address
     * @Assert\NotNull
     * @Assert\NotBlank(message="Address cannot be blank")
     * @Assert\Length(min="2", max="150")
     * @JMS\Type("string")
     * @JMS\SerializedName("address")
     */
    public $address;
    
    /**
     * @var ZipCode
     * @Assert\Valid
     * @JMS\Type("App\ApiModule\Dtos\ZipCode")
     * @JMS\SerializedName("zipCode")
     */
    public $zipCode;
    
}

This Customer DTO has nested ZipCode:

#ApiModule/Dtos/ZipCode.php

namespace App\ApiModule\Dtos;

use JMS\Serializer\Annotation as JMS;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Class ZipCode
 */
class ZipCode implements ApiResponsesInterface
{
    /**
     * @var integer Id
     * @JMS\Type("integer")
     * @JMS\SerializedName("id")
     */
    public $id;
    
    /**
     * @var string zipCode
     * @Assert\NotNull
     * @Assert\NotBlank(message="Zip Code cannot be blank")
     * @Assert\Length(min="2", max="15")
     * @JMS\Type("string")
     * @JMS\SerializedName("zipCode")
     */
    public $zipCode;
    
    /**
     * @var City
     * @Assert\Valid
     * @JMS\Type("App\ApiModule\Dtos\City")
     * @JMS\SerializedName("city")
     */
    public $city;
        
}

Which in turn has nested City DTO:

namespace App\ApiModule\Dtos;

use JMS\Serializer\Annotation as JMS;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Class City
 */
class City implements ApiResponsesInterface
{
    /**
     * @var integer Id
     * @JMS\Type("integer")
     * @JMS\SerializedName("id")
     */
    public $id;
    
    /**
     * @var string City Name
     * @Assert\NotNull
     * @Assert\NotBlank(message="City Name cannot be blank")
     * @Assert\Length(min="2", max="100")
     * @JMS\Type("string")
     * @JMS\SerializedName("cityName")
     */
    public $cityName;
    
    /**
     * @var State
     * @Assert\Valid
     * @JMS\Type("App\ApiModule\Dtos\State")
     * @JMS\SerializedName("state")
     */
    public $state;
    
}

 Which in turn has nested State DTO:

namespace App\ApiModule\Dtos;

use JMS\Serializer\Annotation as JMS;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Class State
 */
class State implements ApiResponsesInterface
{
    /**
     * @var integer Id
     * @JMS\Type("integer")
     * @JMS\SerializedName("id")
     */
    public $id;
    
    /**
     * @var string stateName
     * @Assert\NotNull
     * @Assert\NotBlank(message="State Name cannot be blank")
     * @Assert\Length(min="2", max="100")
     * @JMS\Type("string")
     * @JMS\SerializedName("stateName")
     */
    public $stateName;
    
    /**
     * @var string $stateShortName
     * @Assert\NotNull
     * @Assert\NotBlank(message="State Name cannot be blank")
     * @Assert\Length(min="2", max="45")
     * @JMS\Type("string")
     * @JMS\SerializedName("stateShortName")
     */
    public $stateShortName;
    
    /**
     * @var Country
     * @Assert\Valid
     * @JMS\Type("App\ApiModule\Dtos\Country")
     * @JMS\SerializedName("country")
     */
    public $country;
    
}

 Which in turn has nested Country DTO:

namespace App\ApiModule\Dtos;

use JMS\Serializer\Annotation as JMS;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * Class Country
 */
class Country implements ApiResponsesInterface
{
    /**
     * @var integer Id
     * @JMS\Type("integer")
     * @JMS\SerializedName("id")
     */
    public $id;
    
    /**
     * @var string $countryName
     * @Assert\NotNull
     * @Assert\NotBlank(message="Country Name cannot be blank")
     * @Assert\Length(min="2", max="100")
     * @JMS\Type("string")
     * @JMS\SerializedName("countryName")
     */
    public $countryName;
    
}

 Notice that each DTO has its own properties and each one has its validation constraints.

The body of this PUT Http Request look like:

{
	"id": 4995,
	"firstName": "alexandra",
	"lastName": "mackay",
	"primaryEmail": "This email address is being protected from spambots. You need JavaScript enabled to view it.",
	"phoneNumber": "(003)177-9563x173",
	"address": "925 wilber rapids",
	"zipCode": {
		"id": 1,
		"zipCode": "89049",
		"city": {
			"id": 16613,
			"cityName": "tonopah",
			"state": {
				"id": 28,
				"stateName": "nevada",
				"stateShortName": "NV",
				"country": {
					"id": 1,
					"countryName": "United States"
				}
			}
		}
	}
}

 If this Json String is successfully de-serialized, then the next step is to validate each one of its values. This happens in the same apply method in the param converter:

//...

        //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));
            }
        }

//...

 Finally, if no validation errors, then pass the Customer DTO as the $request argument in the  updateCustomerAction Method:

//...

public function updateCustomerAction(DTO\Customer $request, CustomersService $customersService)
{
   //...

If you dump the $request argument here, you will see the json string transformed into its corresponding Customer DTO including all nested ones:

^ App\ApiModule\Dtos\Customer {#4221 ▼
  +id: 4995
  +firstName: "alexandra"
  +lastName: "mackay"
  +primaryEmail: "This email address is being protected from spambots. You need JavaScript enabled to view it."
  +phoneNumber: "(003)177-9563x173"
  +address: "925 wilber rapids"
  +zipCode: App\ApiModule\Dtos\ZipCode {#4222 ▼
    +id: 1
    +zipCode: "89049"
    +city: App\ApiModule\Dtos\City {#4183 ▼
      +id: 16613
      +cityName: "tonopah"
      +state: App\ApiModule\Dtos\State {#4239 ▼
        +id: 28
        +stateName: "nevada"
        +stateShortName: "NV"
        +country: App\ApiModule\Dtos\Country {#4238 ▼
          +id: 1
          +countryName: "United States"
        }
      }
    }
  }
}

 Once the DTO is available, you may want to implement something like this in a Customer Service Class:

#ApiModule/Services/CustomersService.php

//...

    /**
     * @param DTO\Customer $apiCustomer
     * @return Customers
     */
    public function upsertCustomer(DTO\Customer $apiCustomer): Customers
    {
        try {
            $customer = $apiCustomer->id ? $this->em->getRepository(Customers::class)->find($apiCustomer->id): null;
            if (!$customer) {
                //Create New Customer
                $customer = new Customers();
                $isNew = true;
            }
            $upsertCustomerTransformer = new UpsertCustomerTransformer($this->em, $this->piiCryptoService, $customer, $apiCustomer, $isNew ?? false);
            $customer = $upsertCustomerTransformer->transform();
            $this->em->persist($customer);
            $this->em->flush();
        } catch (\Throwable $e) {
            throw new PreconditionFailedException('Exceptions can be catched here: ' . $e->getMessage());
        }
        
        return $customer;
    }

//...

And a Transformer like this:

#ApiModule/Transformers/UpsertCustomerTransformer.php

namespace App\ApiModule\Transformers;

use App\ApiModule\Dtos as DTO;
use App\ApiModule\Dtos\Customer;
use App\ApiModule\Entity\Customers;
use App\ApiModule\Entity\ZipCodes;
use App\ApiModule\Exceptions\PreconditionFailedException;
use App\ApiModule\Services\PiiCryptoService;
use Doctrine\ORM\EntityManagerInterface;

class UpsertCustomerTransformer
{
    /**
     * @var EntityManagerInterface
     */
    private EntityManagerInterface $em;
    
    /**
     * @var PiiCryptoService
     */
    private PiiCryptoService $piiCryptoService;
    
    /**
     * @var Customers
     */
    private Customers $customer;
    
    /**
     * @var DTO\Customer
     */
    private DTO\Customer $apiCustomer;
    
    /**
     * @var bool
     */
    private bool $isNew;
    
    /**
     * @param EntityManagerInterface $em
     * @param Customers $customer
     * @param Customer $apiCustomer
     * @param bool $isNew
     */
    public function __construct(EntityManagerInterface $em, PiiCryptoService $piiCryptoService , Customers $customer, DTO\Customer $apiCustomer, bool $isNew)
    {
        $this->em = $em;
        $this->piiCryptoService = $piiCryptoService;
        $this->customer = $customer;
        $this->apiCustomer = $apiCustomer;
        $this->isNew = $isNew;
    }
    
    public function transform(): Customers
    {
        if ($this->validate()) {
            $this->customer->setFirstName($this->apiCustomer->firstName);
            $this->customer->setLastName($this->apiCustomer->lastName);
            $this->customer->setPrimaryEmail($this->apiCustomer->primaryEmail);
            $this->customer->setPhoneNumber($this->apiCustomer->phoneNumber);
            $this->customer->setAddress($this->apiCustomer->address);
            $this->customer->setZipCode($this->getZipCode());
        }
        
        return $this->customer;
    }
    
    /**
     * @return bool
     */
    private function validate(): bool
    {
        //Check for dupes
        if ($this->isNew) {
            $enc = $this->piiCryptoService->hashData($this->apiCustomer->primaryEmail);
            $dec = $this->piiCryptoService->unHashData($enc);
            $customers = $this->em->getRepository(Customers::class)->findOneBy(['primaryEmail' => $this->piiCryptoService->hashData($this->apiCustomer->primaryEmail)]);
            if ($customers) {
                throw new PreconditionFailedException('A Customer with the same email already exists');
            }
        }
        
        return true;
    }
    
    /**
     * @return ZipCodes
     */
    private function getZipCode(): ZipCodes
    {
        return $this->em->getRepository(ZipCodes::class)->find($this->apiCustomer->zipCode->id);
    }
    
}

In this case, the Customers Entity has a Many To One association to the Zipcodes Entity, which in turn has a Many To One to the Cities, then to the States then to the Countries ones.

 

For this particular case, this endpoint is for updating a Customer Entity, then you should only pass the Customer Data along with the Zip Code Id, that the frontend should have in some sort of Dropdown to choose from. City, State and Country should be processed in a different endpoint:

{
	"id": 4996,
	"firstName": "alexandra",
	"lastName": "mackay",
	"primaryEmail": "This email address is being protected from spambots. You need JavaScript enabled to view it.",
	"phoneNumber": "(003)177-9563x173",
	"address": "925 wilber rapids",
	"zipCode": {
		"id": 2
	}
}

 However, you can use the same DTOs for building the json string for the response to this request:

 

For more on building a Json Response from DTOs: How to serialize nested Dtos in PHP Symfony 6+

 

Related Videos