How to use custom DTO’s in API Platform?

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?

How to use Extensions in API Platform properly?

Imagine you’re working on this big application which involves an API and a React frontend. You decide on using API Platform for your REST API because of its ease of use. Because the users security is a great concern you want to find an easy way to make sure a user can only access their own data. Ofcourse you can create custom actions where you manually filter the returned data from the database. But this can be really tedious if you have a lot of database entities. To combat this, we can leverage extensions in API Platform.

Extensions in API Platform

Introducing the extensions of API Platform. These classes are called for every request that comes in. These extension classes are able to change the DQL generated by the platform. To create an extension you need to create a class implementing ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface and / or ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface.

If you create a class implemting one or both of these interfaces they will automatically be picked up by the symfony dependency injection container. If you don’t use auto wiring you will need to tag the classes as follows:

services:    
    App\Infrastructure\Api\Extension\ExampleExtension:
        tags:
            - { name: api_platform.doctrine.orm.query_extension.collection }
            - { name: api_platform.doctrine.orm.query_extension.item }Code language: CSS (css)

User filtering using API Platform’s Extensions

In our large application we want to make sure users can only access their own data. To do this efficiently we can harness the power of extensions. Let’s say for example we have a Profile entity and an Address entity which are linked to a user. Both of these entities have a property called user which contains the user it belongs to. To make sure we don’t fetch the data of the wrong user we can use an extension:

namespace App\Infrastructure\Api\Extension;

use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use App\Entity\Address;
use App\Entity\Profile;
use App\Entity\User;
use Doctrine\ORM\QueryBuilder;
use Symfony\Component\Security\Core\Security;

/**
 * This extension makes sure normal users can only access their own Addresses and PRofiles
 */
final class CurrentUserExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
    /**
     * @param Security $security
     */
    public function __construct(private Security $security) {}

    /**
     * @param QueryBuilder $queryBuilder
     * @param QueryNameGeneratorInterface $queryNameGenerator
     * @param string $resourceClass
     * @param string|null $operationName
     */
    public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null): void
    {
        $this->addWhere($queryBuilder, $resourceClass);
    }

    /**
     * @param QueryBuilder $queryBuilder
     * @param QueryNameGeneratorInterface $queryNameGenerator
     * @param string $resourceClass
     * @param array $identifiers
     * @param string|null $operationName
     * @param array $context
     */
    public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, array $identifiers, string $operationName = null, array $context = []): void
    {
        $this->addWhere($queryBuilder, $resourceClass);
    }

    /**
     * @param QueryBuilder $queryBuilder
     * @param string $resourceClass
     *
     */
    private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
    {
        if (Address::class !== $resourceClass
            || Profile::class !== $resourceClass
            || $this->security->isGranted(User::ROLE_ADMIN)
            || null === $user = $this->security->getUser()
        ) {
            return;
        }

        $rootAlias = $queryBuilder->getRootAliases()[0];
        $queryBuilder->andWhere(sprintf('%s.user = :current_user', $rootAlias));

        /** @var User $user */
        $queryBuilder->setParameter('current_user', $user->getId());
    }
}Code language: PHP (php)

We inject the Symfony\Component\Security\Core\Security class so we are able to get the current user of the request. We need to implement the applyToCollection and the applyToItem functions for the two extension interfaces. These methods are called by the API Platform when we do a GET collection request or a GET item request respectively. Because we want to perform the same action in both cases we created the method addWhere which receives the QueryBuilder and the resource class.

Because we only want to change the query for certain cases we check if the resourceClass is supported by the extension or if the user has the role ROLE_ADMIN. If the resourceClass is not supported or the user is an admin we simple exit the function, since we don’t need to add the filter. If it is a supported class and the user is not an admin we use the query builder to add a where clause making sure we only fetch data for the current user.

Protip

If you find yourself adding alot of resourceClass checks to your if statement, try using an interface. You can make an interface called UserDataInterface and change the if statement to check if the resourceClass is an implementation of this interface. For example:

namespace App\Domain\Interface;

use App\Entity\User;

interface UserDataInterface 
{
    public function getUser(): User;
    // ... More methods if needed
}Code language: PHP (php)

You can now change your if-statement to the following:

// ... removed for brevity
private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void
{
    if (is_subclass_of($resourceClass, UserDataInterface::class)
        || $this->security->isGranted(User::ROLE_ADMIN)
        || null === $user = $this->security->getUser()
    ) {
        return;
    }

    // ... filter code
}Code language: PHP (php)

It’s a small change, with a large impact. The function is_subclass_of checks if the class (first parameter can be an instance or a string) is a subclass of another class, or interface (the second parameter). So now this extension will add the filter for every class we mark with implements UserDataInterface.

Do you have any useful tips or tricks using API Platform? Leave them in the comments!