This example is very similar to the previously one where we simplified the way of handling request, response and exceptions in Symfony APIs. The only difference is, we are not calling validation methods in controller anymore. It is done with an even listener. I personally prefer this version.


Prerequisite


Make sure that the jms/serializer-bundle and symfony/validator composer packages are installed. Also don't forget to add JMS\SerializerBundle\JMSSerializerBundle::class => ['all' => true] to bundles.php file.


AbstractController


Every controller must extent this class.


declare(strict_types=1);

namespace App\Controller;

use App\Model\RequestModelInterface;
use App\Model\ResponseModelInterface;
use App\Util\Request\SerializerUtilInterface;
use Symfony\Component\HttpFoundation\Response;

abstract class AbstractController
{
public const REQUEST_CONTENT_TYPE = 'application/json';
public const RESPONSE_FORMAT = 'json';

private $serializerUtil;
private $queryString;
private $payload;

public function __construct(SerializerUtilInterface $serializerUtil)
{
$this->serializerUtil = $serializerUtil;
}

public function setQueryString(?RequestModelInterface $queryString): void
{
$this->queryString = $queryString;
}

protected function getQueryString(): ?RequestModelInterface
{
return $this->queryString;
}

public function setPayload(?RequestModelInterface$payload): void
{
$this->payload = $payload;
}

protected function getPayload(): ?RequestModelInterface
{
return $this->payload;
}

protected function createResponse(?ResponseModelInterface $content, int $status = Response::HTTP_OK): Response
{
return new Response(
$this->serializerUtil->serializeResponseData($content),
$status,
['Content-Type' => self::REQUEST_CONTENT_TYPE]
);
}
}

AppleController


All you are interest below is getQueryString(), getPayload() and createResponse() methods. Rest is just for demonstration purposes.


declare(strict_types=1);

namespace App\Controller;

use App\Model\Apple\Result;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Component\HttpFoundation\Response;

/**
* @Route("/apples")
*/
class AppleController extends AbstractController
{
/**
* @Route("", methods="GET")
*/
public function getAll(): Response
{
$result = new Result();
$result->data = $this->getQueryString();
$result->createdAt = date('H:i:s');

return $this->createResponse($result);
}

/**
* @Route("", methods="POST")
*/
public function create(): Response
{
$result = new Result();
$result->data = $this->getPayload();
$result->createdAt = date('H:i:s');

return $this->createResponse($result);
}
}

Models


Configuration


When you have new request models, you must add them here.


parameters:
controllers:
AppleController:
actions:
create: 'App\Model\Apple\Create'
getAll: 'App\Model\Apple\QueryString'

RequestModelInterface


Every request related models must implement this class.


declare(strict_types=1);

namespace App\Model;

interface RequestModelInterface
{
}

QueryString


declare(strict_types=1);

namespace App\Model\Apple;

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

class QueryString implements RequestModelInterface
{
/**
* @Assert\NotBlank(message="Required field.")
*
* @Serializer\Type("integer")
*/
public $page;

/**
* @Serializer\Type("integer")
*/
public $limit;
}

Create


declare(strict_types=1);

namespace App\Model\Apple;

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

class Create implements RequestModelInterface
{
/**
* @Assert\NotBlank(message="Required field.")
*
* @Serializer\Type("string")
*/
public $name;

/**
* @Serializer\Type("string")
*/
public $colour;
}

ResponseModelInterface


Every response related models must implement this class.


declare(strict_types=1);

namespace App\Model;

interface ResponseModelInterface
{
}

Result


declare(strict_types=1);

namespace App\Model\Apple;

use App\Model\ResponseModelInterface;

class Result implements ResponseModelInterface
{
public $data;

public $createdAt;
}

Exceptions


AbstractApplicationException


Every custom exception class must extend this class.


declare(strict_types=1);

namespace App\Exception;

use Exception;
use Symfony\Component\HttpKernel\Exception\HttpException;

abstract class AbstractApplicationException extends HttpException
{
public function __construct(string $message, int $code, Exception $previous = null)
{
parent::__construct($code, $message, $previous);
}
}

ValidationException


declare(strict_types=1);

namespace App\Exception;

class ValidationException extends AbstractApplicationException
{
}

Listeners


ControllerListener


This is the one that automatically handles request serialisation and validation for you.


declare(strict_types=1);

namespace App\Event\Listener;

use App\Controller\AbstractController;
use App\Util\Request\SerializerUtilInterface;
use App\Util\Request\ValidatorUtilInterface;
use Symfony\Component\HttpKernel\Event\FilterControllerEvent;

class ControllerListener
{
private $serializerUtil;
private $validatorUtil;

public function __construct(
SerializerUtilInterface $serializerUtil,
ValidatorUtilInterface $validatorUtil
) {
$this->serializerUtil = $serializerUtil;
$this->validatorUtil = $validatorUtil;
}

public function onKernelController(FilterControllerEvent $event)
{
if (!$event->isMasterRequest()) {
return;
}

$controller = $event->getController();
if (!is_array($controller)) {
return;
}

if (!$controller[0] instanceof AbstractController) {
return;
}

$request = $event->getRequest();

$this->validatorUtil->validateContentType($request->headers->get('content_type'));

$queryStringData = $this->serializerUtil->deserializeQueryString($request, $controller[0]);
$payloadData = $this->serializerUtil->deserializePayload($request, $controller[0]);

if ($queryStringData) {
$this->validatorUtil->validateRequestData($queryStringData);
}

if ($payloadData) {
$this->validatorUtil->validateRequestData($payloadData);
}

$controller[0]->setQueryString($queryStringData);
$controller[0]->setPayload($payloadData);
}
}

services:
App\Event\Listener\ControllerListener:
tags:
- { name: kernel.event_listener, event: kernel.controller, method: onKernelController }

ExceptionListener


declare(strict_types=1);

namespace App\Event\Listener;

use App\Controller\AbstractController;
use App\Exception\AbstractApplicationException;
use Exception;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseForExceptionEvent;

class ExceptionListener
{
private $logger;

public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}

public function onKernelException(GetResponseForExceptionEvent $event): void
{
if ($event->getException() instanceof AbstractApplicationException) {
$response = $this->handleKnownExceptions($event->getException());
} else {
$response = $this->handleUnknownExceptions($event->getException());
}

$event->setResponse($response);
}

private function handleKnownExceptions(Exception $exception): Response
{
$header = [];

if (Response::HTTP_BAD_REQUEST === $exception->getStatusCode()) {
$header = ['Content-Type' => AbstractController::REQUEST_CONTENT_TYPE];
} else {
$this->logger->error($exception);
}

return new Response($exception->getMessage(), $exception->getStatusCode(), $header);
}

private function handleUnknownExceptions(Exception $exception): Response
{
$this->logger->error($exception);

return new Response('An unknown exception occurred.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
}

services:
App\Event\Listener\ExceptionListener:
tags:
- { name: kernel.event_listener, event: kernel.exception, method: onKernelException }

SerializerUtil


declare(strict_types=1);

namespace App\Util\Request;

use App\Controller\AbstractController;
use App\Model\RequestModelInterface;
use App\Model\ResponseModelInterface;
use Symfony\Component\HttpFoundation\Request;

interface SerializerUtilInterface
{
public function deserializeQueryString(Request $request, AbstractController $controller): ?RequestModelInterface;

public function deserializePayload(Request $request, AbstractController $controller): ?RequestModelInterface;

public function serializeResponseData(?ResponseModelInterface $content): string;
}

declare(strict_types=1);

namespace App\Util\Request;

use App\Controller\AbstractController;
use App\Model\RequestModelInterface;
use App\Model\ResponseModelInterface;
use Exception;
use JMS\Serializer\SerializationContext;
use JMS\Serializer\SerializerInterface;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\HttpFoundation\Request;

class SerializerUtil implements SerializerUtilInterface
{
private $serializer;
private $controllers;

public function __construct(
SerializerInterface $serializer,
iterable $controllers
) {
$this->serializer = $serializer;
$this->controllers = $controllers;
}

public function deserializeQueryString(Request $request, AbstractController $controller): ?RequestModelInterface
{
$data = null;

if ($request->query->all()) {
$data = $this->createRequestModel(
$this->getRequstModelName($request, $controller),
json_encode($request->query->all())
);
}

return $data;
}

public function deserializePayload(Request $request, AbstractController $controller): ?RequestModelInterface
{
$data = null;

if ($request->getContent()) {
$data = $this->createRequestModel(
$this->getRequstModelName($request, $controller),
$request->getContent()
);
}

return $data;
}

public function serializeResponseData(?ResponseModelInterface $content): string
{
$context = new SerializationContext();
$context->setSerializeNull(true);

return $this->serializer->serialize($content, AbstractController::RESPONSE_FORMAT, $context);
}

private function getRequstModelName(Request $request, AbstractController $controller): ?string
{
$parts = explode('\\', get_class($controller));
$controllerName = end($parts);
$parts = explode('::', $request->attributes->get('_controller'));
$actionName = end($parts);

try {
return $this->controllers[$controllerName]['actions'][$actionName];
} catch (Exception $e) {
throw new InvalidConfigurationException(sprintf(
'Configuration is missing for %s::%s',
$controllerName,
$actionName
));
}
}

private function createRequestModel(string $requestModelName, string $data): RequestModelInterface
{
return $this->serializer->deserialize($data, $requestModelName, AbstractController::RESPONSE_FORMAT);
}
}

ValidatorUtil


declare(strict_types=1);

namespace App\Util\Request;

use App\Model\RequestModelInterface;

interface ValidatorUtilInterface
{
public function validateContentType(?string $contentType): void;

public function validateRequestData(RequestModelInterface $model): void;
}

declare(strict_types=1);

namespace App\Util\Request;

use App\Controller\AbstractController;
use App\Exception\ValidationException;
use App\Model\RequestModelInterface;
use Doctrine\Common\Inflector\Inflector;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationListInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;

class ValidatorUtil implements ValidatorUtilInterface
{
private $validator;

public function __construct(ValidatorInterface $validator)
{
$this->validator = $validator;
}

public function validateContentType(?string $contentType): void
{
if ($contentType && AbstractController::REQUEST_CONTENT_TYPE !== $contentType) {
throw new ValidationException(
'Invalid content type header.',
Response::HTTP_UNSUPPORTED_MEDIA_TYPE
);
}
}

public function validateRequestData(RequestModelInterface $model): void
{
$errors = $this->validator->validate($model);
if ($errors->count() > 0) {
throw new ValidationException($this->createErrorMessage($errors), Response::HTTP_BAD_REQUEST);
}
}

private function createErrorMessage(ConstraintViolationListInterface $violations): string
{
$errors = [];

/** @var ConstraintViolation $violation */
foreach ($violations as $violation) {
$errors[Inflector::tableize($violation->getPropertyPath())] = $violation->getMessage();
}

return json_encode(['errors' => $errors]);
}
}

Tests


$ curl -i -X POST \
> http://192.168.99.20:81/api/v1/apples \
> -d '{
> "name": "Pink Lady",
> "colour": "pink"
> }'

HTTP/1.1 415 Unsupported Media Type
Content-Type: text/html; charset=UTF-8

Invalid content type header

$ curl -i -X POST \
> http://192.168.99.20:81/api/v1/apples \
> -H 'Content-Type: application/json' \
> -d ''

HTTP/1.1 200 OK
Content-Type: application/json

{
"data": null,
"created_at": "21:27:03"
}

$ curl -i -X POST \
> http://192.168.99.20:81/api/v1/apples \
> -H 'Content-Type: application/json' \
> -d '{
> "name": "Pink Lady",
> "colour": "pink"
> }'

HTTP/1.1 200 OK
Content-Type: application/json

{
"data": {
"name": "Pink Lady",
"colour": "pink"
},
"created_at": "21:26:03"
}

$ curl -i -X POST \
> http://192.168.99.20:81/api/v1/apples \
> -H 'Content-Type: application/json' \
> -d '{
> "name": "",
> "colour": "pink"
> }'

HTTP/1.1 400 Bad Request
Content-Type: application/json

{"errors":{"name":"Required field."}}


$ curl -i -X GET \
> 'http://192.168.99.20:81/api/v1/apples' \
> -H 'Content-Type: application/xml'

HTTP/1.1 415 Unsupported Media Type
Content-Type: text/html; charset=UTF-8

Invalid content type header.

$ curl -i -X GET \
> 'http://192.168.99.20:81/api/v1/apples'

HTTP/1.1 200 OK
Content-Type: application/json

{
"data": null,
"created_at": "21:30:37"
}

$ curl -i -X GET \
> 'http://192.168.99.20:81/api/v1/apples?page=1&limit=10'

HTTP/1.1 200 OK
Content-Type: application/json

{
"data": {
"page": 1,
"limit": 10
},
"created_at": "21:30:00"
}