Normally in API Platform all requests are linked to an entity or another model class. API Platform then creates the basic CRUD (create, read, update, delete) endpoints, such as GET, POST, PUT and DELETE. This is fine for most basic applications, but most of the time you need custom endpoints with custom data. This is where DTO’s (Data Transfer Objects) come in handy. In this guide I’ll show you how to use custom DTO’s in API Platform.
What are we going to build?
To explain the concepts more easily I’m going to create a custom endpoint for user registration in our REST API. We need a custom endpoint because we want to restrict the POST
method on the /api/users
endpoint to admins only with some special features. The endpoint we’ll create will be a POST
on /api/users/register
which will be used in our frontend application for user registration.
To make sense of it all I’ll show you my user entity:
namespace App\Entity;
use ApiPlatform\Core\Annotation\ApiResource;
use App\Repository\UserRepository;
use DateTime;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Serializer\Annotation\Groups;
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ApiResource(
collectionOperations: [
'get' => [
'security' => 'is_granted("ROLE_ADMIN")',
],
'post' => [
'security' => 'is_granted("ROLE_ADMIN")',
],
],
itemOperations: [
'get' => [
'security' => 'object == user or is_granted("ROLE_ADMIN")',
],
'put' => [
'security' => 'object == user or is_granted("ROLE_ADMIN")',
],
'delete' => [
'security' => 'is_granted("ROLE_ADMIN")',
],
],
denormalizationContext: ['groups' => ['user:write']],
normalizationContext: ['groups' => ['user:read']],
)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
const ROLE_ADMIN = 'ROLE_ADMIN';
const ROLE_USER = 'ROLE_USER';
#[ORM\Id()]
#[ORM\Column(type: 'guid', unique: true)]
#[ORM\GeneratedValue(strategy: 'UUID')]
#[Groups(['user:read'])]
private string $id;
#[ORM\Column(type: "string", length: 1024, nullable: false)]
#[Groups(['user:read', 'user:write'])]
private string $email;
#[ORM\Column(type: "string", length: 255, nullable: false)]
#[Groups(['user:write'])]
private string $password;
#[ORM\Column(type: "string", length: 255, nullable: false)]
#[Groups(['user:read', 'user:write'])]
private string $firstName;
#[ORM\Column(type: "string", length: 255, nullable: false)]
#[Groups(['user:read', 'user:write'])]
private string $lastName;
#[ORM\Column(type: "string", length: 20, nullable: false)
#[Groups(['user:read', 'user:write'])]
private string $role;
#[ORM\Column(type: "boolean")]
#[Groups(['user:read', 'user:write'])]
private bool $active;
// Getters and setters removed for brevity
}
Code language: PHP (php)
As you can see it’s a fairly basic user entity with fields for the credentials and some userdata. All of the endpoints are accessible for admins only, with the exception of GET /api/users/{id}
and PUT /api/users/{id}
which allows users to change their own profile and read their own profile. The properties returned are managed by the serialization groups user:read
and user:write
.
What is a DTO?
First I’ll explain the term DTO. It’s an acronym for Data Transfer Object. It’s a simple object used to transfer data from one place to another. Let’s start with an example.
namespace App\Dto\Request\User;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Register User Request DTO
*/
class RegisterUserRequest
{
public string $firstName;
public string $lastName;
public string $email;
public string $password;
}
Code language: PHP (php)
As you can see this is a very simple PHP class with public properties. You can also make the properties private and use getters and setters to access the values.
Creating the custom endpoint
In order to create the custom endpoint we first need to create a controller class. Let’s call it Register
.
namespace App\Controller\User;
use App\Application\Service\UserService;
use App\Dto\Request\User\RegisterUserRequest;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Handles the registration of a new user
*/
class Register extends AbstractController
{
/**
* @param UserService $userService
* @param SerializerInterface $serializer
*/
public function __construct(private UserService $userService, private SerializerInterface $serializer) {}
/**
* @param Request $request
*
* @return User
*/
public function __invoke(Request $request): User
{
$dto = $this->serializer->deserialize($request->getContent(), RegisterUserRequest::class, 'json');
return $this->userService->register($dto);
}
}
Code language: PHP (php)
We use the Symfony serializer to convert the request data to our custom DTO. This DTO is sent to the userService
which handles the actual creation of an account for the user.
Next we need to make sure API Platform knows about this controller and when to use it. To do this we can register this controller in our user entity.
namespace App\Entity;
// Uses removed for brevity
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ApiResource(
collectionOperations: [
'get' => [
'security' => 'is_granted("ROLE_ADMIN")',
],
'post' => [
'security' => 'is_granted("ROLE_ADMIN")',
],
'register' => [
'method' => 'POST',
'status' => 204,
'path' => '/users/register',
'controller' => Register::class,
'read' => false,
'deserialize' => false,
],
],
itemOperations: [
// Removed for brevity
],
denormalizationContext: ['groups' => ['user:write']],
normalizationContext: ['groups' => ['user:read']],
)]
#[UniqueEntity(fields: ['email'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
// Removed for brevity
}
Code language: PHP (php)
As you can see I’ve added a register
entry to the collectionOperations
property of the ApiResource
attribute. This will create an extra endpoint which we can reach on /api/users/register
. I’ve set the status to 204 because we will return a 204 No Content
response when user registration was successful. When something went wrong we’ll return a 4xx
or a 5xx
http code to indicate problems with registration.
The read
and deserialize
options are set to false, because we don’t want API Platform to try and deserialize requests to this endpoint. We’re handling this ourselves in the controller.
Using events to automate this
You can imagine when you have a lot of custom requests it can be really tedious to inject the serializer and to the deserialization manually all the time. Of course we can change this, we are developers after all. We can leverage the events emitted by API Platform. In our case we’ll create a subscriber that listens to KernelEvents::REQUEST
and EventPriorities::PRE_READ
this will trigger our event on every request, before data is read by API Platform.
namespace App\Event\Subscriber;
use ApiPlatform\Core\EventListener\EventPriorities;
use ApiPlatform\Core\Validator\ValidatorInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Serializer\SerializerInterface;
/**
* Serializes the request data to the given DTO
*/
final class DtoSerializerEventSubscriber implements EventSubscriberInterface
{
/**
* @param SerializerInterface $serializer
* @param ValidatorInterface $validator
*/
public function __construct(private SerializerInterface $serializer, private ValidatorInterface $validator)
{}
/**
* @param RequestEvent $event
*/
public function serializeToDto(RequestEvent $event): void
{
$request = $event->getRequest();
if (false === $request->attributes->has('dto')
|| false === in_array($request->getMethod(), [Request::METHOD_POST, Request::METHOD_PUT, Request::METHOD_PATCH], true)
) {
return;
}
$dto = $this->serializer->deserialize($request->getContent(), $request->attributes->get('dto'), 'json');
$this->validator->validate($dto);
$request->attributes->set('dto', $dto);
}
/**
* @return array[]
*/
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => ['serializeToDto', EventPriorities::PRE_READ],
];
}
}
Code language: PHP (php)
In this subscriber we check if it’s a request that modifies data (POST
, PUT
, PATCH
), because we can expect custom DTO’s in these request methods. We also check if the request has a dto
attribute, if one of these checks fail, we simple exit the event subscriber by returning.
When all checks passed we deserialize the request data using the classname in the dto
attribute. As an added bonus we also validate the DTO using attributes from the Validator component of Symfony. This makes sure our DTO is always valid, and if it’s not the API Platform will return the validation failures for us. Very nice!
After the validation is done we simple put the deserialized DTO in the request using $request->attributes->set('dto', $dto);
We still have one loose end to fix. How do we get the classname of the DTO we want to use in the request attributes? Simple! We can add it to the declaration of our custom endpoint like this.
namespace App\Entity;
// Uses removed for brevity
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[ApiResource(
collectionOperations: [
'get' => [
'security' => 'is_granted("ROLE_ADMIN")',
],
'post' => [
'security' => 'is_granted("ROLE_ADMIN")',
],
'register' => [
'method' => 'POST',
'status' => 204,
'path' => '/users/register',
'controller' => Register::class,
'defaults' => [
'dto' => RegisterUserRequest::class,
],
'read' => false,
'deserialize' => false,
],
],
itemOperations: [
// Removed for brevity
],
denormalizationContext: ['groups' => ['user:write']],
normalizationContext: ['groups' => ['user:read']],
)]
#[UniqueEntity(fields: ['email'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
// Removed for brevity
}
Code language: PHP (php)
We’ve added defaults
to the declaration of our register
endpoint in which we specify the DTO to use.
Using this event subscriber will make our future custom controllers easier to use because we can simply get the serialized DTO from the request attributes. To do this use $request->attributes->get('dto')
. So our custom controller now looks like this.
namespace App\Controller\User;
use App\Application\Service\UserService;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
/**
* Handles the registration of a new user
*/
class Register extends AbstractController
{
/**
* @param UserService $userService
*/
public function __construct(private UserService $userService) {}
/**
* @param Request $request
*
* @return User
*/
public function __invoke(Request $request): User
{
return $this->userService->register($request->attributes->get('dto'));
}
}
Code language: PHP (php)
Very nice and clean code, just the way I like it!
Validation of custom DTO’s
As I’ve mentioned in the previous section I’ve added the validator to the event subscriber to validate the DTO’s. To make full use of this we need to alter our request DTO a tiny bit. The validator component needs to know how to validate every property of the DTO. We need to add special attributes to the property in order to do this.
namespace App\Dto\Request\User;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Register User Request DTO
*/
class RegisterUserRequest
{
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
public string $firstName;
#[Assert\NotBlank]
#[Assert\Length(max: 255)]
public string $lastName;
#[Assert\NotBlank]
#[Assert\Email]
#[Assert\Length(max: 1024)]
public string $email;
#[Assert\NotBlank]
#[Assert\Length(min: 8)]
public string $password;
}
Code language: PHP (php)
These Assert\*
attributes will tell the validator how to validate the properties of our DTO.
The use of different Symfony components makes it very easy to create custom endpoints for our API with custom DTO’s.
What do you think of this way of using custom DTO’s in API Platform?