Configuring API Platform resources by following DDD principles

API Platform is a powerful API building tool created on top of the Symfony framework. It makes bootstraping your API resources with CRUD functionalities by annotating your entities with the @ApiResource() annotation almost trivial.

But, as it turns out, there are some drawbacks to that approach. By using the API Platform, you are coupling your domain entities with the API Platform resources and you are exposing your domain entities to the client. And don’t even get me started on the DDD principles.

Luckily, amazing people behind the API Platform have thought about that and have added a mechanism to decouple your entities from your API resources.

In this post, I’m going to show you how to configure your API resources in an XML format, decoupled from your domain models, and I’ll also add a cherry on top with a couple of elements regarding serialization.

How to make our entities/domain models

<?php

namespace App\Entity;

class Post
{
    private int $id;

    private string $title;

    private Comment $comment;

    public function getTitle(): string
    {
        return $this->title;
    }

    public function setTitle(string $title): void
    {
        $this->title = $title;
    }

    public function getComment(): Comment
    {
        return $this->comment;
    }

    public function setcomment(Comment $comment): void
    {
        $this->comment = $comment;
    }
}
<?php

namespace App\Entity;

class Comment
{
    private int $id;

    private string $text;

    public function getText(): string
    {
        return $this->text;
    }

    public function setText(string $text): void
    {
        $this->text = $text;
    }
}

And that’s it, now we have our domain models.

Let’s repeat what we are aiming for:

  1. Our domain models to be decoupled from the API Platform configuration
  2. Our domain models to not be the same as API resources

How to configure our API resources in an XML format

First, let’s tweak our API Platform configuration file to read mappings from our XML file:

// config/packages/api_platform.yaml
api_platform:
    mapping:
        paths: ['%kernel.project_dir%/config/api_platform/resources.xml']

Now, after we have told the API Platform where to look for API resources configuration, the only thing left is to create our XML file.

// config/api_platform/resources.xml
<?xml version="1.0" encoding="UTF-8" ?>

<resources xmlns="https://api-platform.com/schema/metadata"
           xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
           xsi:schemaLocation="https://api-platform.com/schema/metadata
           https://api-platform.com/schema/metadata/metadata-2.0.xsd">
    <resource class="App\Entity\Post">
        <attribute name="normalization_context">
            <attribute name="groups">post:read</attribute>
        </attribute>
        <attribute name="denormalization_context">
            <attribute name="groups">post:write</attribute>
    </attribute>
    <itemOperations>
        <itemOperation name="get">
            <attribute name="method">GET</attribute>
            <attribute name="output">App\Dto\PostDTO</attribute>
        </itemOperation>
    </itemOperations>
    <collectionOperations>
        <collectionOperation name="post">
            <attribute name="method">POST</attribute>
            <attribute name="input">App\Dto\PostDto</attribute>
            <attribute name="output">App\Dto\PostDto</attribute>
        </collectionOperation>
    </collectionOperations>
</resource>

Our API resources configuration is pretty simple. We have made our model available as a resource, supporting a single item retrieval and item creation, but with a twist. And we’ve also added the input and output attributes to those operations.

Input and output attributes are saying which classes are accepted as objects for reading and creating a resource instance. Or as the docs say:

The input attribute is used during the deserialization process, when transforming the user-provided data to a resource instance. Similarly, the output attribute is used during the serialization process.

We have also added attributes for serialization groups (denormalization and normalization contexts), which allow us to choose which resource attributes will be exposed during a serialization process.

Next step — we should create our DTOs.

How to create our DTOs

<?php

namespace App\Dto;

final class PostDto
{
    public int $id;
    public string $title;
    public CommentDto $comment;
}<?php

namespace App\Dto;

final class CommentDto
{
    public int $id;
    public string $text;
}

Great, that was easy.
Let’s continue and create data transformers that will be in charge of converting our input DTO to the resource object and the other way around.

How to create data transformers

An input transformer:

<?php
namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use ApiPlatform\Core\Validator\ValidatorInterface;
use App\Dto\PostDto;
use App\Entity\Post;

final class PostInputTransformer implements DataTransformerInterface
{
    /**
     * @var ValidatorInterface
     */
    private $validator;

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

    public function transform(PostDto $object, string $to, array $context = []): Post
    {
        $this->validator->validate($object);

        $post = new Post();
        $post->setTitle($object->title);
        $post->setComment($object->comment);
    }

    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        if ($data instanceof Post) {
            return false;
        }

        return $to === Post::class && ($context['input']['class'] ?? null) !== null;
    }
}

And an output transformer:

<?php

namespace App\DataTransformer;

use ApiPlatform\Core\DataTransformer\DataTransformerInterface;
use App\Dto\PostDto;
use App\Entity\Post;

final class PostOutputTransformer implements DataTransformerInterface
{
    public function transform(Post $object, string $to, array $context = []): PostDto
    {
        $output = new PostDto();
        $output->title = $object->getTitle();
        $output->comment = $object->getComment();

        return $output;
    }

    public function supportsTransformation($data, string $to, array $context = []): bool
    {
        return $to === PostDto::class && $data instanceof Post;
    }
}

And we are done!
We have successfully achieved our goals:

  1. Our models are decoupled from the API Platform configuration
  2. Our models are not resources exposed through the API

And as a cherry on top, let’s make use of those aforementioned serialization groups.

How to configure serialization groups in an XML format

Like we already did, let’s update our framework configuration file first:

// config/packages/framework
framework:
    serializer:
        mapping:
            paths: ['%kernel.project_dir%/config/serializer/serialization.xml']

And now we can create that XML file, and add groups to all of the attributes that we want to be serialized and deserialized:

<?xml version="1.0" ?>
<serializer xmlns="http://symfony.com/schema/dic/serializer-mapping"
            xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
            xsi:schemaLocation="http://symfony.com/schema/dic/serializer-mapping
        https://symfony.com/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd"
>
<class name="App\Dto\PostDto">
        <attribute name="id">
            <group>post:read</group>
        </attribute>
        <attribute name="title">
            <group>post:read</group>
            <group>post:write</group>
        </attribute>
        <attribute name="comment">
            <group>post:read</group>
            <group>post:write</group>
        </attribute>
    </class>
    <class name="App\Dto\CommentDto">
        <attribute name="id">
            <group>post:read</group>
        </attribute>
        <attribute name="text">
            <group>post:read</group>
            <group>post:write</group>
        </attribute>
    </class>
</serializer>

What we configured in serialization.xml is:

  • When /GET request is sent to retrieve a Post, our API will expose:
{
    "id": 1,
    "title": "Title",
    "comment": {
        "id": 1,
        "text": "Text"
    }
}
  • When a /POST request is sent to create a new Post, we allow a Comment to be created together with it, and payload sent to the API should look like:
{
    "title": "Title",
    "comment": {
        "text": "Text"
    }
}

And that’s that — thank you for sticking with me. Happy configuring!