PMG Digital Made for Humans

Trading Symfony’s Form Component for Data Transfer Objects

8 MINUTE READ | September 3, 2019

Trading Symfony’s Form Component for Data Transfer Objects

Author's headshot

Christopher Davis

Christopher Davis has written this article. More details coming soon.

At PMG, we have been using Symfony (or its components) for a long time. I made my first commit on my first Silex in February 2013. In the following six years, we built a lot of Symfony forms — mostly by hand as PMG has not done a lot of work with ORMs where something like EntityType could be used.

As our web applications moved toward APIs and single-page frontends, I wanted a layer between the domain model and API output. This was an idea I stole from Fractal and implemented with a custom normalizer and Symfony’s serializer component.

One API endpoint (like /entities) and a developer would have to know to edit the form to change how POST /entities behaves and edit the custom normalizer to change how GET /entities rendered a response. It seemed like there should be one place to manage all of that for a given entity.

The Symfony form component is incredibly powerful. It’s also incredibly complex. It’s almost a right of passage for someone on the PMG development team(s) to be stuck in what we call Symfony form hell at least once trying to bend the form component to do something complex such as various event listeners, data transformers, or one of the other many extensions points the component provides.

This isn’t meant to be a dig on the form component. It’s complex, but powerful and handles edges cases that most of us will never hit. But part of my gig as a lead developer is to help my team be more productive. The form component has a lot of cognitive overhead.

A data transfer object (or DTO) is something that’s only meant to shuttle data around. Usually these are struct-like objects with no behavior. They may be immutable, but most often I think of them as having public properties.

The data transfer objects here are meant to demonstrate handling incoming requests or outgoing data in an API.

Say there is a Client entity in an application with id, name, and siteUrl properties.

client_entity.php

<?phpclass Client{    private $id;    private $name;    private $siteUrl;    // constructor, getters, etc}

Our data transfer object should reflect how we want the Client to appear in the API. So if we want snake_case fields, property names should reflect that (this may make linters complain).

client_dto_1.php

<?phpclass ClientDto{    public $id;    public $name;    public $site_url;}

Sending a response from a controller is creating a DTO (I like to use a named constructor for this) then serializing the DTO to JSON via the serializer component and sending a JsonResponse. All this can be accomplished with the json method from AbstractController.

controller.php

<?phpuse Symfony\Bundle\FrameworkBundle\Controller\AbstractController;class ClientController extends AbstractController{    public function viewClientAction(string $clientId) : Response    {        $client = $this->getClientOr404($clientId);        return $this->json(ClientDto::fromClient($client));    }}

We have the same nice layer between the API output and the domain entity that a custom normalizer or fractal can provide. And there’s no magic stuff from the serializer component like a name converter.

This is where it gets a bit tricky. To deserialize data into a DTO, we need to think more about validation and serialization groups. Both of these things can be done via annotations. The comment-based annotations are something that I’ve actively avoided for almost my entire time using Symfony and Doctrine. I don’t think they belong anywhere near domain code, but in the case of an integration layer between the domain entities and an API annotations seem, at worst, acceptable.

Serialization groups provide a way to whitelist which properties should be serialized or deserialized. Any properties not part of the serialization group are ignored by the serializer. There are probably at least three groups that would be necessary for DTOs meant for an API:

  1. default — the properties sent out in responses

  2. create — the properties accepted in a request to create entities

  3. update — the properties accepted in an update request

To start, let’s define a few constants and a base Dto class for serialization groups.

dto.php

<?phpabstract class Dto{    public const GROUP_DEFAULT = 'default';    public const GROUP_CREATE = 'create';    public const GROUP_UPDATE = 'update';}

Then, we need to define what attributes belong to which group in our DTO via the Groups annotation. Let’s say the ID property is a UUID generated by the server so it only belongs to the default group. The name can only be set when the client is created and thus belongs to the default and create groups. Site URL can be set on create or update, and belongs to both those groups along with the default group.

client_dto_2.php

<?phpuse Symfony\Component\Serializer\Annotation\Groups;class ClientDto extends Dto{    /**     * @Groups(Dto::GROUP_DEFAULT)     */    public $id;    /**     * @Groups({Dto::GROUP_DEFAULT, Dto::GROUP_CREATE})     */    public $name;    /**     * @Groups({Dto::GROUP_DEFAULT, Dto::GROUP_CREATE, Dto::GROUP_UPDATE})     */    public $site_url;}

With groups added we then need to update our controller to use the default group on output. This is done via the the $context parameter in the json method.

controller_2.php

<?phpuse Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\Response;class ClientController extends AbstractController{    public function viewClientAction(string $clientId) : Response    {        $client = $this->getClientOr404($clientId);        return $this->json(ClientDto::fromClient($client), Response::HTTP_OK, [], [            'groups' => Dto::GROUP_DEFAULT,        ]);    }}

With groups in place we now have a way to accept certain attributes on client create or update.

We can add validation constraints via annotations as well. Keeping with the client example, validation should verify that the name and site URL are non-empty strings and verify that the site URL is, in fact, a URL.

It’s important, at this point, that we mention that adding type information via @var PHPDoc annotations is important on DTOs. Symfony can use that info via property info component (and property access) as it deserializes JSON into DTOs. I’ve found, however, that the serializer component throwing an exception for type mis-matches is worse for the user than using a Type constraint which can provide a better error message. You can disable the serializer’s type enforcement by setting disable_type_enforcement flag to false in the context passed to the serializer.

Validation constraints have groups too, so we can re-use our GROUP_* constants for validation.

client_dto_3.php

<?phpuse Symfony\Component\Serializer\Annotation\Groups;use Symfony\Component\Validator\Constraints as Assert;class ClientDto extends Dto{    /**     * @Groups(Dto::GROUP_DEFAULT)     * @var string     */    public $id;    /**     * @Groups({Dto::GROUP_DEFAULT, Dto::GROUP_CREATE})     * @Assert\NotBlank(groups={Dto::GROUP_CREATE, Dto::GROUP_UPDATE})     * @Assert\Type(type="string", groups={Dto::GROUP_CREATE, Dto::GROUP_UPDATE})     * @var string     */    public $name;    /**     * @Groups({Dto::GROUP_DEFAULT, Dto::GROUP_CREATE, Dto::GROUP_UPDATE})     * @Assert\NotBlank(groups={Dto::GROUP_CREATE, Dto::GROUP_UPDATE})     * @Assert\Type(type="string", groups={Dto::GROUP_CREATE, Dto::GROUP_UPDATE})     * @Assert\Url(protocols={"http", "https"}, groups={Dto::GROUP_CREATE, Dto::GROUP_UPDATE})     * @var string     */    public $site_url;}

With groups and validation in place, we are ready to accept incoming JSON requests, deserialize them to a DTO, then validate the DTO and use it to build actual domain objects.

controller_3.php

<?phpuse Symfony\Bundle\FrameworkBundle\Controller\AbstractController;use Symfony\Component\HttpFoundation\Request;use Symfony\Component\HttpFoundation\Response;class ClientController extends AbstractController{    // ...    public function createClientAction(Request $request) : Response    {        // probably want to validate that the request is JSON and such here...        // this will throw a NotEncodableValueException from the serializer component        // if the JSON is invalid. May want to catch and convert to a bad request        // exception.        $dto = $this->get('serializer')->deserialize(            $request->getContent(),             ClientDto::class,            'json',            ['groups' => Dto::GROUP_CREATE]        );        $validationErrors = $this->get('validator')->validate($dto, null, [Dto::GROUP_CREATE]);        if (count($validationError) > 0) {            return $this->json($validationErrors, Response::HTTP_BAD_REQUEST);        }        $client = $this->createAndStoreClientFromDto($dto);        return $this->json(ClientDto::fromClient($client), Response::HTTP_CREATED, [            'Location' => $this->generateUrl('clients.view', [                'clientId' => $client->getId(),            ]),        ], [            'groups' => Dto::GROUP_DEFAULT,        ]);    }}

The above example skips validating that the incoming request has a JSON content type. Real-life code should probably do that. We also are pretty loose with catching serializer exceptions which should be caught and mapped to an appropriate HTTP response.

The other interesting thing to note here is that Symfony provides a custom normalizer for constraint violation lists (the type returned from the validate method in the above example) that will turn them into an API Problem response.

In a real app, you’ll probably want to build (and test!) abstractions around the common DTO operations in the controllers here.

The biggest benefit, in my mind, is the symmetry of the DTO. The same object is used to define the incoming requests and outgoing responses. One place to look for things. One place to change things.

The serializer also has tooling to handle one of the most common things I end up using form event listeners for: adding or changing properties based on another property — called a class descriminator mapping in Symfony parlance. Writing fewer form event listeners is always a win.

How the properties appear in the API’s JSON are also explicitly tied to the property names in the data transfer object. Need a custom name for an entity’s property on output? Add that custom property to a DTO without worrying that the change may impact other objects. And that change will be reflected with data coming into the API as well.

Stay in touch

Bringing news to you

Subscribe to our newsletter

By clicking and subscribing, you agree to our Terms of Service and Privacy Policy

Overall, I like this approach. It’s something I’ve wanted to try for a long time and it’s held up well in a real application that PMG deployed earlier this year.


Related Content

thumbnail image

AlliPMG CultureCampaigns & Client WorkCompany NewsDigital MarketingData & Technology

PMG Innovation Challenge Inspires New Alli Technology Solutions

4 MINUTES READ | November 2, 2021

thumbnail image

Applying Function Options to Domain Entities in Go

11 MINUTES READ | October 21, 2019

thumbnail image

My Experience Teaching Through Jupyter Notebooks

4 MINUTES READ | September 21, 2019

thumbnail image

Working with an Automation Mindset

5 MINUTES READ | August 22, 2019

thumbnail image

3 Tips for Showing Value in the Tech You Build

5 MINUTES READ | April 24, 2019

thumbnail image

Testing React

13 MINUTES READ | March 12, 2019

thumbnail image

A Beginner’s Experience with Terraform

4 MINUTES READ | December 20, 2018

thumbnail image

Tips for Holiday Reporting Preparedness

3 MINUTES READ | November 5, 2018

ALL POSTS