Search code examples
phpsymfonyserializationsymfony5

How to deserialize a nested array of objects declared on the constructor via promoted properties, with Symfony Serializer?


Take the following DTO classes:

class UserDTO {
    /**
     * @param AddressDTO[] $addressBook
     */
    public function __construct(
        public string $name,
        public int $age,
        public ?AddressDTO $billingAddress,
        public ?AddressDTO $shippingAddress,
        public array $addressBook,
    ) {
    }
}

class AddressDTO {
    public function __construct(
        public string $street,
        public string $city,
    ) {
    }
}

I'd like to serialize and deserialize them to/from JSON.

I'm using the following Serializer configuration:

$encoders = [new JsonEncoder()];

$extractor = new PropertyInfoExtractor([], [
    new PhpDocExtractor(),
    new ReflectionExtractor(),
]);

$normalizers = [
    new ObjectNormalizer(null, null, null, $extractor),
    new ArrayDenormalizer(),
];

$serializer = new Serializer($normalizers, $encoders);

But when serializing/deserializing this object:

$address = new AddressDTO('Rue Paradis', 'Marseille');
$user = new UserDTO('John', 25, $address, null, [$address]);

$jsonContent = $serializer->serialize($user, 'json');
dd($serializer->deserialize($jsonContent, UserDTO::class, 'json'));

I get the following result:

UserDTO^ {#54
  +name: "John"
  +age: 25
  +billingAddress: AddressDTO^ {#48
    +street: "Rue Paradis"
    +city: "Marseille"
  }
  +shippingAddress: null
  +addressBook: array:1 [
    0 => array:2 [
      "street" => "Rue Paradis"
      "city" => "Marseille"
    ]
  ]
}

When I would expect:

UserDTO^ {#54
  +name: "John"
  +age: 25
  +billingAddress: AddressDTO^ {#48
    +street: "Rue Paradis"
    +city: "Marseille"
  }
  +shippingAddress: null
  +addressBook: array:1 [
    0 => AddressDTO^ {#48
      +street: "Rue Paradis"
      +city: "Marseille"
    }
  ]
}

As you can see, $addressBook is deserialized as an array of array, instead of an array of AddressDTO. I expected the PhpDocExtractor to read the @param AddressDTO[] from the constructor, but this does not work.

It only works if I make $addressBook a public property documented with @var.

Is there a way to make it work with a simple @param on the constructor?

(Non-)working-demo: https://phpsandbox.io/n/gentle-mountain-mmod-rnmqd


What I've read and tried:

None of the proposed solutions seem to work for me.


Solution

  • Apparently the issue is that the PhpDocExtractor does not extract properties from constructors. You need to use a specific extractor for this:

    use Symfony\Component\PropertyInfo;
    use Symfony\Component\Serializer;
    
    $phpDocExtractor = new PropertyInfo\Extractor\PhpDocExtractor();
    $typeExtractor   = new PropertyInfo\PropertyInfoExtractor(
        typeExtractors: [ new PropertyInfo\Extractor\ConstructorExtractor([$phpDocExtractor]), $phpDocExtractor,]
    );
    
    $serializer = new Serializer\Serializer(
        normalizers: [
                        new Serializer\Normalizer\ObjectNormalizer(propertyTypeExtractor: $typeExtractor),
                        new Serializer\Normalizer\ArrayDenormalizer(),
                     ],
        encoders:    ['json' => new Serializer\Encoder\JsonEncoder()]
    );
    

    With this you'll get the desired results. Took me a bit to figure it out. The multiple denormalizer/extractor chains always get me.


    Alternatively, for more complex os specialized situations, you could create your own custom denormalizer:

    use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
    use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface;
    use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait
    
    class UserDenormalizer
        implements DenormalizerInterface, DenormalizerAwareInterface
    {
    
        use DenormalizerAwareTrait;
    
        public function denormalize($data, string $type, string $format = null, array $context = [])
        {
            $addressBook = array_map(fn($address) => $this->denormalizer->denormalize($address, AddressDTO::class), $data['addressBook']);
    
            return new UserDTO(
                name:            $data['name'],
                age:             $data['age'],
                billingAddress:  $this->denormalizer->denormalize($data['billingAddress'], AddressDTO::class),
                shippingAddress: $this->denormalizer->denormalize($data['shippingAddress'], AddressDTO::class),
                addressBook:     $addressBook
            );
        }
    
        public function supportsDenormalization($data, string $type, string $format = null)
        {
            return $type === UserDTO::class;
        }
    }
    

    Setup would become this:

    $extractor = new PropertyInfoExtractor([], [
        new PhpDocExtractor(),
        new ReflectionExtractor(),
        
    ]);
    
    $userDenormalizer = new UserDenormalizer();
    $normalizers      = [
        $userDenormalizer,
        new ObjectNormalizer(null, null, null, $extractor),
        new ArrayDenormalizer(),
    
    ];
    $serializer       = new Serializer($normalizers, [new JsonEncoder()]);
    $userDenormalizer->setDenormalizer($serializer);
    

    Output becomes what you would expect:

    ^ UserDTO^ {#39
      +name: "John"
      +age: 25
      +billingAddress: AddressDTO^ {#45
        +street: "Rue Paradis"
        +city: "Marseille"
      }
      +shippingAddress: null
      +addressBook: array:2 [
        0 => AddressDTO^ {#46
          +street: "Rue Paradis"
          +city: "Marseille"
        }
      ]
    }