The requirements and history
The task was to integrate a CRM (Emarsys) into the e-commerce platform.
The CRM provides predefined system fields and field types. 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: Male => 1 Female => 2 Divers => 6 |
Firstname | firstName | 1 | Short text |
Lastname | lastName | 2 | Short text |
3 | Long text | ||
Birthdate | birthday | 4 | Date: YYYY-MM-DD |
Marketing Information * | marketing_information | 100674 | Single-choice: Yes* => 1 No* => 2 |
*custom
For a creation of a contact in CRM, with this data
- Salutation: Female
- Name: Jane Doe
- Email: jane.doe@example.com
- Birthday: 1989-11-09
- Marketing Information: Yes
the payload must be:
{
"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 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 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.
FieldIDs with attribute #[SerializedName]
First the FieldIDs. This is easily done with the #[SerializedName]
attribute.
<?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
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 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 for transforming data. For example, there is the DateTimeNormalizer 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 and DenormalizerInterface 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 ($salutation
) and BooleanValue ($marketingInformation
). The supportsNormalization()
and supportsDenormalization()
methods check whether the MappingTableNormalizer is responsible for the object.
Here is the full implementation of the MappingTableNormalizer:
<?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
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.
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.
framework:
serializer:
default_context: '%serializer_default_context%'
parameters:
serializer_default_context:
skip_null_values: true
Normalize the ContactDto to an array
<?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
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) it tries to get the type declaration 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 as the type declaration.
Instead, I want the getters and setters to have the type declaration string or bool:
<?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
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 (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) that you can look at on github.
<?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
- Symfony Serializer with default service configuration
- Symfony Serializer with custom service configuration (PropertyNormalizer)
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)
- Change GitHub Repository URL (Sep 4th 2024)
Top comments (0)