resolved through the IriConverter * - an array value is an embedded object -> denormalized into the concrete entity */ final class ContractRelationDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface { use DenormalizerAwareTrait; private const CONTRACT_NAMESPACE = 'App\Shared\Domain\Contract\\'; /** @var array */ private array $resolved = []; public function __construct( private readonly IriConverterInterface $iriConverter, private readonly EntityManagerInterface $entityManager, ) {} public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { return null !== $this->concreteClassFor($type); } public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): ?object { $concrete = $this->concreteClassFor($type); if (null === $concrete) { return null; } if (is_string($data)) { return $this->iriConverter->getResourceFromIri($data, $context); } // Embedded object payload: denormalize into the resolved concrete entity. return $this->denormalizer->denormalize($data, $concrete, $format, $context); } public function getSupportedTypes(?string $format): array { // Support depends on the runtime-resolved Doctrine mapping, so it cannot be // statically cached by the serializer. return ['object' => false]; } /** * @return ?class-string the concrete entity a contract interface resolves to, or null */ private function concreteClassFor(string $type): ?string { if (array_key_exists($type, $this->resolved)) { return $this->resolved[$type]; } if (!str_starts_with($type, self::CONTRACT_NAMESPACE) || !interface_exists($type)) { return $this->resolved[$type] = null; } try { $name = $this->entityManager->getClassMetadata($type)->getName(); } catch (Throwable) { // Not a Doctrine-mapped (resolve_target_entities) interface. return $this->resolved[$type] = null; } return $this->resolved[$type] = ($name !== $type ? $name : null); } }