Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[ObjectMapper] Infer nested target class from destination property type - #65970

Open
iz-ahmad wants to merge 1 commit into
symfony:8.2from
iz-ahmad:objectmapper-nested-property-inference
Open

[ObjectMapper] Infer nested target class from destination property type#65970
iz-ahmad wants to merge 1 commit into
symfony:8.2from
iz-ahmad:objectmapper-nested-property-inference

Conversation

@iz-ahmad

@iz-ahmad iz-ahmad commented Sep 10, 2026

Copy link
Copy Markdown
Q A
Branch? 8.2
Bug fix? no
New feature? yes
Deprecations? no
Issues Fixes #65380
License MIT

A class that declares #[Map(source: ...)] says how it is built from that source. The mapper used that declaration for the object it maps at the top level, and for a nested object whose target class was listed in a class map, but not for a nested object in the general case. Such a value was copied as it is, so this failed:

final class ParentEntity
{
    public function __construct(public string $name, public NestedEntity $nested) {}
}

#[Map(source: ParentEntity::class)]
final class ParentEntityResource
{
    public function __construct(public string $name = '', public ?NestedEntityResource $nested = null) {}
}

(new ObjectMapper())->map(new ParentEntity('Laptop', new NestedEntity('Electronics')), ParentEntityResource::class);
// TypeError: Argument #2 ($nested) must be of type ?NestedEntityResource, NestedEntity given

NestedEntityResource declares #[Map(source: NestedEntity::class)], and $nested is typed with it, so there is enough information to build it. In a Symfony application this already worked, because the bundle collects every service carrying #[Map] into a class map. Standalone, or for a class that is not a service, it did not.

What this adds

Symfony\Component\ObjectMapper\Metadata\PropertyTypeMappingMetadataFactory, a metadata factory that decorates another one:

new ObjectMapper(new PropertyTypeMappingMetadataFactory(new ReflectionObjectMapperMetadataFactory()));

It answers only when the factory it decorates reports no mapping for the value. Then it reads the class typing the property the value is written into, and maps the value to that class if it declares a matching #[Map(source: ...)].

It is enabled by default. new ObjectMapper() builds it around ReflectionObjectMapperMetadataFactory, and object_mapper.metadata_factory.property_type decorates object_mapper.metadata_factory in the container. In both it is the outermost decorator, so an explicit #[Map], a class map entry and enum conversion all take precedence. Pass your own factory to ObjectMapper to leave it out; the factory you pass is then the only authority on mappings.

When it applies

All of these must hold:

  • the value's own class, and every factory in the chain, report no mapping for it;
  • the mapper can write the destination property (it is public, or a constructor parameter, or a property accessor can write it);
  • the property is typed with a single class or interface, and the value is not already an instance of it;
  • that class declares #[Map(source: S)] with the value being an S.

A property typed with a union, an intersection or nothing at all is left alone, and so is a property whose type the value already satisfies, so ?Animal receiving a Dog still stores the Dog.

How it reports problems

  • Several #[Map(source: ...)] on the destination property type match the value, for example one naming the class and one naming an interface it implements: MappingException, "Ambiguous mapping ... Use the "if" parameter to specify conditions." Add an if condition to one of them.
  • The destination property type is an abstract class or an interface, and it declares a matching source without a transform: MappingException, Cannot infer a mapping target for "X" from property "Y::$z": class "T" is not instantiable. Type the property with a concrete class, or give the #[Map] a transform that returns the instance.
  • The destination property cannot be written by the mapper: nothing is resolved and nothing is reported, exactly as before.

New API

The $context argument of ObjectMapperMetadataFactoryInterface::create() now carries two keys the mapper fills for a nested value, documented on the interface: target, the class the value is written into, and target_property, the property it is written into. They are set only when the mapper can write that property. Custom factories may read them; they are optional and absent for other calls.

Points for the documentation

  • The four conditions above, and that this needs no configuration.
  • The precedence order: an explicit #[Map], then the class map, then this inference.
  • How to opt out, and that passing any factory to ObjectMapper opts out unless you compose PropertyTypeMappingMetadataFactory yourself. This matters for the common new ObjectMapper(new ReflectionObjectMapperMetadataFactory(), $propertyAccessor).
  • The two MappingException messages and what to do about each.
  • The two new context keys, for anyone writing a metadata factory.

@iz-ahmad

Copy link
Copy Markdown
Author

I see the CI is red on Unit Tests (8.4, high-deps) and Unit Tests (8.4, low-deps), but not from this change. ObjectMapper passes cleanly in both jobs. The failures are pre-existing on 8.2: WebProfilerBundle, SecurityBundle, Validator, Translation, and an HttpClient/symfony/config version mismatch (NodeBuilder::appendFromCallback() undefined).

@symfony symfony deleted a comment from carsonbot Sep 13, 2026
…e of the destination property

A class that declares #[Map(source: ...)] describes how it is built from that source.
Until now the mapper only used that declaration for the object it maps at the top level,
or for a nested object whose target class was listed in a class map. A nested object whose
own class declares nothing was copied as it is, which fails when the destination property
is typed with the resource class.

PropertyTypeMappingMetadataFactory resolves that case. It decorates another metadata
factory and answers only when that factory reports no mapping for the value: it reads the
class typing the property the value is written into, and maps the value to it when that
class declares a matching #[Map(source: ...)]. It is the outermost decorator, both in the
chain the no-argument ObjectMapper constructor builds and in the container, so an explicit
mapping always wins.

The mapper passes the destination to the metadata factory through the context, under the
"target" and "target_property" keys, and only when it can write that property. Resolution
therefore never runs for a value the mapper would throw away.

A property typed with a union, an intersection or nothing at all is left alone, as is a
property whose type the value already satisfies. A property typed with an abstract class or
an interface that declares a matching source is refused with a MappingException, since the
mapper cannot instantiate it.
@nicolas-grekas
nicolas-grekas force-pushed the objectmapper-nested-property-inference branch from d98e731 to 71dc628 Compare September 13, 2026 11:23
@nicolas-grekas

Copy link
Copy Markdown
Member

Thank you for your patience on this one, and for the diagnosis. The gap you found is real: a class that declares #[Map(source: ...)] describes how it is built, and that declaration went unused for a nested object unless the target class was already in a class map. Your fixtures pinned the case down and your getPropertyTypeMetadata() had the resolution rule right.

I reworked where it runs. In the branch as it stood, the resolution lived in ObjectMapper::getSourceValue(), which has three consequences:

  1. It goes around ObjectMapperMetadataFactoryInterface, the component's extension point and the mapper's first constructor argument. A custom factory that answers "no mapping for this class" was overruled: I ran one that hides a single mapping and delegates the rest, and the branch mapped the value anyway. The decorators, EnumMappingMetadataFactory and ReverseClassObjectMapperMetadataFactory, never saw the inferred mapping either.
  2. getSourceValue() also runs for a destination property the mapper will never write, such as a non-public one with no property accessor. On 8.2 nothing happens there; with the resolution inside, it could throw. Two cases that map cleanly today started failing: an ambiguous inference on a private property raised Ambiguous mapping, and an abstract one on a protected property raised Cannot instantiate abstract class.
  3. class_exists() is true for abstract classes, so an abstract destination property type reached newLazyGhost() and threw a raw \Error, outside the component's exception hierarchy. An interface-typed property was silently skipped instead.

It also helped that your getPropertyTypeMetadata() was nearly the if (!$property) branch of ReverseClassObjectMapperMetadataFactory::create(): the logic already belonged in the metadata layer.

So the resolution now lives in a new PropertyTypeMappingMetadataFactory, a metadata factory decorator composed like the existing two. The mapper only passes the destination to the factory, through the $context argument the interface already carried, and only when it can actually write that property, so case 2 cannot come back. A non-instantiable destination type is refused with a MappingException naming the property. The factory sits in the default chain the no-argument ObjectMapper constructor builds and is registered in Resources/config/object_mapper.php, so your case needs no configuration.

On the fixtures: Tests/Fixtures/Bundle already had the same pair, and ObjectMapperBundleTest::testMapNestedObjectWhoseClassDeclaresNoMapping already covered the container version of the scenario, so the standalone test now reuses them and is the exact counterpart of that one. The new fixture directory holds only what nothing covered: the abstract, interface, ambiguous, non-public and self-referencing cases.

I checked that turning this on by default changes no case that works today. Everything it newly handles fails with a TypeError on 8.2. The one exception is a class with a constructor parameter typed wider than the same-named property, which now receives the mapped object instead of the raw one, and that already matches how filterMetadataByPropertyType() treats the same shape.

One trade-off worth knowing: new ObjectMapper(new ReflectionObjectMapperMetadataFactory(), $propertyAccessor), the usual way to add a property accessor, no longer gets the inference, because passing a factory means that factory decides. Compose PropertyTypeMappingMetadataFactory around it to keep both.

The branch is squashed into one commit with your authorship, rebased on 8.2, and the component and FrameworkBundle suites are green. I also rewrote the description as documentation for the eventual docs pull request.

@nicolas-grekas

Copy link
Copy Markdown
Member

/cc @soyuka WDYT?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[ObjectMapper] No way to map a nested object without putting mapping attributes on the source class

3 participants