Create a custom Symfony Normalizer for mapping values
Tags: symfony, php, api
Published: 2023-04-03 16:33:06

---
title: "Create a custom Symfony Normalizer for mapping values"
published: true
description: 
tags: symfony, php, api
series: Mapping FieldValueIDs for the payload of the Emarsys API
---
## <a name="the_requirements_and_history"></a> The requirements and history

The task was to integrate a CRM ([Emarsys](https://emarsys.com/)) into the e-commerce platform.

The CRM provides predefined [system fields](https://help.emarsys.com/hc/en-us/articles/115004637665-Overview-The-Emarsys-system-fields) and [field types](https://help.emarsys.com/hc/en-us/articles/115004634729-overview-available-field-types). [Custom fields](https://help.emarsys.com/hc/en-us/articles/115004634689-End-user-guides-Creating-custom-fields) can also be created. The label for the field and the field name can be assigned by the user. The FieldID for the field and the FieldValueID for the choices are assigned by the CRM when the field is created. The field names are not relevant when interacting with the Emarsys API.

Here are some examples:

| Label                   | Fieldname              | FieldID | Type: FieldValue => FieldValueID                           |
|:------------------------|:-----------------------|:--------|:-----------------------------------------------------------|
| Salutation              | salutation             | 46	     | Single-choice:<br>Male => 1<br>Female => 2<br> Divers => 6 |
| Firstname               | firstName              | 1       | Short text                                                 |
| Lastname                | lastName               | 2       | Short text                                                 |
| Email                   | email                  | 3       | Long text                                                  |
| Birthdate               | birthday               | 4       | Date: YYYY-MM-DD                                           |
| Marketing Information	* | marketing\_information | 100674  | Single-choice:<br>Yes* => 1<br>No* => 2                    |

*custom

For a creation of a contact in CRM, with this data

* Salutation: Female
* Name: Jane Doe
* Email: jane.doe<span>@</span>example<span>.</span>com
* Birthday: 1989-11-09
* Marketing Information: Yes

the payload must be:
```json
{
    "1": "Jane",
    "2": "Doe",
    "3": "john.doe@example.com",
    "4": "1989-11-09",
    "46": "2",
    "100674": "1"
}
```

The response body, for example, when searching for a contact, has the same structure. However, all the fields of the contact are provided.


### Built-in mapping in the API-Client

From the beginning, the [snowcap/emarsys](https://github.com/snowcap/Emarsys) package has been used as the API client. This package is good, extensible and provides a simple mapping (Fieldname <-> FieldId). The system fields are already stored in the package.

The built-in mapping is sufficient if you have only a few custom fields. However, if you have 30 custom fields and multiple accounts in use, it becomes difficult to maintain and manage.

### Use a custom Attribute

So it would be better to configure the FieldID and the mapping to the FieldValueID directly on the properties. So I implemented a custom attribute and a simple PropertyReader and PropertyWriter.

I have taken the solution with the [custom attribute](https://github.com/alaugks/article-php-attribute-emarsys-example) out of the project and made it available on github.

## Using the Symfony serializer component and creating a custom normalizer

I wanted to see if it was possible to replace the custom attribute with features provided by the [Symfony serializer component](https://symfony.com/doc/current/components/serializer.html).

### FieldIDs with attribute #[SerializedName]

First the FieldIDs. This is easily done with the `#[SerializedName]` attribute.


```php
<?php

namespace App\Dto;

use Symfony\Component\Serializer\Annotation\SerializedName;
use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;

class ContactDto
{
    #[SerializedName("1")]
    private ?string $firstname = null;

    #[SerializedName("2")]
    private ?string $lastname = null;

    #[SerializedName("3")]
    private ?string $email = null;

    #[Context([DateTimeNormalizer::FORMAT_KEY => 'Y-m-d'])]
    #[SerializedName("4")]
    private ?\DateTimeInterface $birthdate = null;

    #[SerializedName("46")]
    private ?string $salutation = null;

    #[SerializedName("100674")]
    private ?boolean $marketingInformation = null;
    
    /* getter and setter */
}
```

### Normalize/Denormalize and Serialize/Deserialize

With the Serializer you can normalize, denormalize, serialize and deserialize, encode und decode. With `serialize()`, the `normalize()` and `encode()` are called, and with a `deserialize()`, the `denormalize()` and `decode()` are called.

I use `normalize()` and `denormalize()` because I need an array for the API client:

```php
<?php

use Symfony\Component\Serializer\Serializer;

$contactDto = new ContactDto();
$contactDto->setSalutation('FEMALE');
$contactDto->setFirstname('Jane');
$contactDto->setLastname('Doe');
$contactDto->setEmail('jane.doe@example.com');
$contactDto->setBirthdate(new \DateTimeImmutable('1989-11-09'));
$contactDto->setMarketingInformation(true);

// Normalize
$fields = $this->serializer->normalize($contactDto);
/*
    Array
    (
        [1] => Jane
        [2] => Doe
        [3] => jane.doe@example.com
        [4] => 1989-11-09
        [46] => FEMALE
        [100674] => true
    )
*/

// Denormalize
$contactDto = $this->serializer->denormalize($fields, ContactDto::class);
/*
    App\Dto\ContactDto Object
    (
        [firstname:App\Dto\ContactDto:private] => Jane
        [lastname:App\Dto\ContactDto:private] => Doe
        [email:App\Dto\ContactDto:private] => jane.doe@example.com
        [birthdate:App\Dto\ContactDto:private] => DateTimeImmutable Object
            (
                [date] => 1989-11-09 13:54:11.000000
                [timezone_type] => 3
                [timezone] => UTC
            )

        [salutation:App\Dto\ContactDto:private] => FEMALE
        [marketingInformation:App\Dto\ContactDto:private] => 1
    )
*/
```

This cannot yet be sent to CRM via the API because CRM cannot handle the FieldValue FEMALE for `$salutation` and true for `$marketingInformation`.

For this I create a custom normalizer and denormalizer.

#### ⚠️ Note: #[SerializedName] with a number as name in Symfony 6.2

Denormalize FieldIDs does not work in Symfony 6.2. This has to do with an array_merge() when denormalizing in the symfony/serializer package in version 6.2. I have created a [pull request](https://github.com/symfony/symfony/pull/49700) for a fix. The pull request has already been reviewed. I guess the fix will be merged soon.

> ✅ This bug is fixed in version 6.3.5.

### Create a custom Normalizer

The Symfony serializer component provides several [built-in normalizers](https://symfony.com/doc/5.4/components/serializer.html#built-in-normalizers) for transforming data. For example, there is the [DateTimeNormalizer](https://github.com/symfony/symfony/blob/2ed1fef5af37373f448c260c21e0db19b4be8794/src/Symfony/Component/Serializer/Normalizer/DateTimeNormalizer.php) to transform a DateTime object into a date format and a date format into a DateTime object.

For my case, I need a normalizer that transforms **FEMALE** into FieldValueID **2** for `$salutation` and a denormalizer that transforms FieldValueID **2** into **FEMALE**.

To do this, I create the MappingTableNormalizer (Normalizer and Denormalizer), which implements the [NormalizerInterface](https://github.com/symfony/symfony/blob/5.4/src/Symfony/Component/Serializer/Normalizer/NormalizerInterface.php) and [DenormalizerInterface](https://github.com/symfony/symfony/blob/5.4/src/Symfony/Component/Serializer/Normalizer/DenormalizerInterface.php) interfaces.

The serializer calls the `supportsNormalization()` and `supportsDenormalization()` functions of all registered normalizers and denormalizers to determine which normalizer or denormalizer to use to transform an object.

A custom type must be created to ensure that the MappingTableNormalizer is applied to the `$salutation` and `$marketingInformation` properties.

I have created [StringValue](https://github.com/alaugks/article-serializer/blob/symfony-5.4/app/src/Normalizer/Value/StringValue.php) (`$salutation`) and [BooleanValue](https://github.com/alaugks/article-serializer/blob/symfony-5.4/app/src/Normalizer/Value/BooleanValue.php) (`$marketingInformation`). The `supportsNormalization()` and `supportsDenormalization()` methods check whether the MappingTableNormalizer is responsible for the object.

Here is the full implementation of the [MappingTableNormalizer](https://github.com/alaugks/article-serializer/blob/symfony-5.4/app/src/Normalizer/MappingTableNormalizer.php):

```php
<?php

namespace App\Normalizer;

use App\Normalizer\Value\BooleanValue;
use App\Normalizer\Value\StringValue;
use App\Normalizer\Value\ValueInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

class MappingTableNormalizer implements NormalizerInterface, DenormalizerInterface
{
    public const TABLE = 'mapping_table';

    private const SUPPORTED_TYPES = [
        StringValue::class,
        BooleanValue::class
    ];

    public function normalize(mixed $object, string $format = null, array $context = []): ?string
    {
        $mappingTable = $this->getMappingTable($context);

        $key = array_search($object->getValue(), $mappingTable);
        if($key) {
            return (string)$key; // Force string
        }
        
        return null;
    }

    public function supportsNormalization(mixed $data, string $format = null): bool
    {
        return $data instanceof ValueInterface;
    }

    public function denormalize($data, $type, $format = null, array $context = array()): mixed
    {
        $mappingTable = $this->getMappingTable($context);

        foreach ($mappingTable as $key => $value) {
            if ((string)$key === $data) {
                return new $type($value);
            }
        }

        return new $type(null);
    }

    public function supportsDenormalization($data, $type, $format = null): bool
    {
        return in_array($type, self::SUPPORTED_TYPES);
    }

    private function getMappingTable(array $context): array
    {
        if (!isset($context[self::TABLE]) || !is_array($context[self::TABLE])) {
            throw new \InvalidArgumentException('mapping_table not defined');
        }

        return $context[self::TABLE];
    }
}

```
#### Attribute #[Context] for the MappingTable

The `#[Context]` attribute is used to define the MappingTable on the `$salutation` and `$marketingInformation` properties.

```php
<?php

namespace App\Dto;

use App\Normalizer\MappingTableNormalizer;
use App\Normalizer\Value\BooleanValue;
use App\Normalizer\Value\StringValue;
use Symfony\Component\Serializer\Annotation\Context;
use Symfony\Component\Serializer\Annotation\SerializedName;

class ContactDto
{
    // Other properties

    #[Context([MappingTableNormalizer::TABLE => ['1' => 'MALE', '2' => 'FEMALE', '6' => 'DIVERS']])]
    #[SerializedName("46")]
    private ?StringValue $salutation = null;

    #[Context([MappingTableNormalizer::TABLE => ['1' => true, '2' => false]])]
    #[SerializedName("100674")]
    private ?BooleanValue $marketingInformation = null;

    // Other getter and setter

    public function getSalutation(): ?StringValue
    {
        return $this->salutation;
    }

    public function setSalutation(?StringValue $salutation): void
    {
        $this->salutation = $salutation;
    }

    public function getMarketingInformation(): ?BooleanValue
    {
        return $this->marketingInformation;
    }

    public function setMarketingInformation(?BooleanValue $marketingInformation): void
    {
        $this->marketingInformation = $marketingInformation;
    }
}
```

#### Register the custom normalizer

The normalizer still needs to be registered if you are not using the [default services.yaml configuration](https://symfony.com/doc/current/service_container.html#service-container-services-load-example).

```yaml
services:
  serializer.normalizer.mapping_table_normalizer:
    class: 'App\Normalizer\MappingTableNormalizer'
    tags: ['serializer.normalizer']
```

#### Skip null values

Then there should be no normalization of properties that are null. These properties will also not be included in the normalized array. Fields that are not in the payload will not be updated in CRM.

```yaml
framework:
  serializer:
    default_context: '%serializer_default_context%'

parameters:
  serializer_default_context:
    skip_null_values: true
```

#### Normalize the ContactDto to an array

```php
<?php

use Snowcap\Emarsys\Client;
use Symfony\Component\Serializer\Serializer;

private Client $client;
private Serializer $serializer;

$contactDto = new ContactDto();
$contactDto->setSalutation(new StringValue('FEMALE'));
$contactDto->setFirstname('Jane');
$contactDto->setLastname('Doe');
$contactDto->setEmail('jane.doe@example.com');
$contactDto->setBirthdate(new \DateTimeImmutable('1989-11-09'));
$contactDto->setMarketingInformation(new BooleanValue(true));

// Normalize ContactDto
$fields = $this->serializer->normalize($contactDto);
/*
    Array
    (
        [1] => Jane
        [2] => Doe
        [3] => jane.doe@example.com
        [4] => 1989-11-09
        [46] => 2
        [100674] => 1
    )
*/

// Create Contact (API-Request)
$this->client->createContact($fields)
```

#### Denormalize the array to a ContactDto

```php
<?php

use Snowcap\Emarsys\Client;
use Symfony\Component\Serializer\Serializer;

private Client $client;
private Serializer $serializer;

// Get Contact (API-Request)
$response = $this->client->getContact(['3' => 'jane.doe@example.com']);
$fields = $response->getData()
/*
    Array
    (
        [1] => Jane
        [2] => Doe
        [3] => jane.doe@example.com
        [4] => 1989-11-09
        // other fields
        [46] => 2
        // other fields
        [100674] => 1
        // other fields
    )
*/

// Denormalize Array
$conatctDto = $this->serializer->denormalize($fields, ContactDto::class);
/*
    App\Dto\ContactDto Object
    (
        [firstname:App\Dto\ContactDto:private] => Jane
        [lastname:App\Dto\ContactDto:private] => Doe
        [email:App\Dto\ContactDto:private] => jane.doe@example.com
        [birthdate:App\Dto\ContactDto:private] => DateTimeImmutable Object
            (
                [date] => 1989-11-09 13:54:11.000000
                [timezone_type] => 3
                [timezone] => UTC
            )

        [salutation:App\Dto\ContactDto:private] => App\Normalizer\Value\StringValue Object
            (
                [value:App\Normalizer\Value\StringValue:private] => FEMALE
            )

        [marketingInformation:App\Dto\ContactDto:private] => App\Normalizer\Value\BooleanValue Object
            (
                [value:App\Normalizer\Value\BooleanValue:private] => 1
            )

    )
*/
```  

## Serializer default service configuration

The getters and setters for `$salutation` and `$marketingInformation` have `StringValue` and `BooleanValue` as the type.

This is because the ObjectNormalizer and ReflectionExtractor are enabled by default in symfony applications.

This ObjectNormalizer read and write in the object with the PropertyAccess component. This means that the ObjectNormalizer can access properties directly and through getters, setters, haters, issers, canners, adders and removers.

In the ReflectionExtractor ([PropertyInfo component](https://symfony.com/doc/5.4/components/property_info.html)) it tries to get the [type declaration](https://github.com/symfony/symfony/blob/5.4/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php#L142) from the mutator (set, add, remove), accessor (get, is, has, can) or constructor (in that order) based on the name of the property. If this is not possible, it will get the type from the property's type declaration.

So the ReflectionExtractor gets the type declaration from `setSalutation()`. If we were to define string as a type hint, `$type` for `supportsDenormalization()` would be of type string. Thus, we can no longer ensure that MappingTableNormalizer is applied to `$salutation`. This is because when the serialiser applies the normaliser to the properties, the property is not disclosed, just the value.

In my opinion, this default service configuration cannot be disabled or controlled by configuration parameters.

### PropertyNormalizer and custom PropertyTypeExtractor

I am not fan of this Serializer default service configuration, because I would prefer that the getter and setter for `$salutation` and `$marketingInformation` does not have the [ValueInterface](https://github.com/alaugks/article-serializer/blob/symfony-5.4/app/src/Normalizer/Value/ValueInterface.php) as the type declaration.

Instead, I want the getters and setters to have the type declaration string or bool:

```php
<?php

class ContactDto
{
    private ?StringValue $salutation = null;

    private ?BooleanValue $marketingInformation = null;
    
    public function getSalutation(): ?string
    {
        return $this->salutation?->getValue();
    }

    public function setSalutation(?string $salutation): void
    {
        $this->salutation = new StringValue($salutation);
    }

    public function isMarketingInformation(): ?bool
    {
        return $this->marketingInformation?->getValue();
    }

    public function setMarketingInformation(?bool $marketingInformation): void
    {
        $this->marketingInformation = new BooleanValue($marketingInformation);
    }
}
```

Therefore I need the PropertyNormalizer to read and write directly on the properties and a custom PropertyTypeExtractor to read only the type declaration from the properties.

### Serializer service configuration for my custom requirements

```yaml
services:
    'App\Service\CrmSerializerService':
        arguments:
            - '@crm_serializer'

    crm_serializer:
        class: 'Symfony\Component\Serializer\Serializer'
        arguments:
            $normalizers:
                - '@serializer.normalizer.datetime'
                - '@app.normalizer.mapping_table_normalizer'
                - '@crm_serializer.property_normalizer'
            $encoders: []

    crm_serializer.property_normalizer:
        class: 'Symfony\Component\Serializer\Normalizer\PropertyNormalizer'
        arguments:
            $nameConverter: '@serializer.name_converter.metadata_aware'
            $propertyTypeExtractor: '@crm_serializer.reflection_extractor'
            $defaultContext: '%serializer_default_context%'

    crm_serializer.reflection_extractor:
        class: 'Symfony\Component\PropertyInfo\PropertyInfoExtractor'
        arguments:
            $typeExtractors:
                - '@app.service.property_info.property_type_extractor'

    app.service.property_info.property_type_extractor:
        class: 'App\Service\PropertyInfo\PropertyTypeExtractor'

    app.normalizer.mapping_table_normalizer:
        class: 'App\Normalizer\MappingTableNormalizer'   
```

I use the PropertyNormalizer instead of ObjectNormalizer, because it only reads and writes the value from the property.

Instead of the ReflectionExtractor I use a custom  [PropertyTypeExtractor](https://github.com/alaugks/article-serializer/tree/symfony-5.4-property-normalizer/app/src/Reflection/PropertyTypeExtractor.php) (PropertyTypeExtractorInterface) which reads the type declaration only from the property.

The AdvancedNameConverterInterface is also needed to convert the name of the property (`salutation` <-> `46`).

The $defaultContext is the serializer configuration with skip null values.

Then I populate the serializer with the required objects:
* Normalizer
    * DateTimeNormalizer
    * MappingTableNormalizer
    * PropertyNormalizer
* Encoders
    * Encoders are not needed at all, because only normalization and denormalization are done.

For the service configuration I use named arguments, because the other arguments are taken care of by the autowire and the default serializer service configuration can be used here.

I also have an example as an custum serializer object ([CrmSerializer](https://github.com/alaugks/article-serializer/tree/symfony-5.4-property-normalizer/app/src/Serializer/CrmSerializer.php)) that you can look at on github.

```php
<?php

use App\Dto\ContactDto;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\SerializerInterface;

class CrmSerializerService
{
    private Serializer $serializer; // Only Symfony Serializer

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

    public function normalize(ContactDto $contactDto): array
    {
        return $this->serializer->normalize($contactDto);
    }

    public function denormalize(array $data): ContactDto
    {
        return $this->serializer->denormalize($data, ContactDto::class);
    }
}

$contactDto = new ContactDto();
$contactDto->setSalutation('FEMALE');
/* other setter */
$contactDto->setMarketingInformation(true);

// Normalize ContactDto
$fields = $this->crmSerializerService->normalize($contactDto);
/*
    Array
    (
        [1] => Jane
        [2] => Doe
        [3] => jane.doe@example.com
        [4] => 1989-11-09
        [46] => 2
        [100674] => 1
    )
*/
```

## Conclusion

The Symfony Serializer component is a powerful tool. I have many ideas on how to improve and simplify existing implementations with custom normalisers, especially in existing projects.

## Full examples
* [Custom attribute implementation](https://github.com/alaugks/article-php-attribute-emarsys-example)
* [Symfony Serializer with default service configuration](https://github.com/alaugks/article-serializer/tree/symfony-5.4)
* [Symfony Serializer with custom service configuration (PropertyNormalizer)](https://github.com/alaugks/article-serializer/tree/symfony-5.4-property-normalizer)

## Updates
* Series name defined (May 5th 2023)
* Update series name (May 8th 2023)
* Add anchor for a deeplink (May 7th 2023)
* Add bugfix note. (Nov 10th 2023)
* Fix broken links (Dez 30th 2023)