diff --git a/packages/mapper/src/Mappers/ArrayToObjectMapper.php b/packages/mapper/src/Mappers/ArrayToObjectMapper.php index dbdba1ca91..468a383ef8 100644 --- a/packages/mapper/src/Mappers/ArrayToObjectMapper.php +++ b/packages/mapper/src/Mappers/ArrayToObjectMapper.php @@ -64,6 +64,10 @@ public function map(mixed $from, mixed $to): object if ($isMissing && ($getHook = $property->getGetHook())) { $from[$propertyName] = $getHook->invokeArgs($targetObject); } elseif ($isMissing) { + if ($this->applyPromotedDefault($property, $targetObject)) { + continue; + } + $this->handleMissingProperty( property: $property, propertyName: $propertyName, @@ -133,6 +137,28 @@ private function resolveObject(mixed $objectOrClass): object return new ClassReflector($objectOrClass)->newInstanceWithoutConstructor(); } + /** + * Readonly properties cannot declare a class-level default, so a promoted + * constructor parameter is the only place a default can live for them. + * Returns false when no default could be applied. + */ + private function applyPromotedDefault(PropertyReflector $property, object $targetObject): bool + { + if (! $property->isReadonly() || ! $property->hasDefaultValue()) { + return false; + } + + $parameter = $property->getClass()->getConstructor()?->getParameter($property->getName()); + + if ($parameter === null || ! $parameter->isDefaultValueAvailable()) { + return false; + } + + $property->setValue($targetObject, $parameter->getDefaultValue()); + + return true; + } + private function setParentRelations(object $parent, ClassReflector $parentClass): void { static $plans = []; diff --git a/tests/Integration/Mapper/Fixtures/ObjectWithPromotedDefaults.php b/tests/Integration/Mapper/Fixtures/ObjectWithPromotedDefaults.php new file mode 100644 index 0000000000..5cd20e3b05 --- /dev/null +++ b/tests/Integration/Mapper/Fixtures/ObjectWithPromotedDefaults.php @@ -0,0 +1,17 @@ +assertNull($object->b); } + #[Test] + public function map_promoted_defaults_without_running_constructor(): void + { + ObjectWithPromotedDefaults::$constructorCalls = 0; + + $object = map(['name' => 'given'])->to(ObjectWithPromotedDefaults::class); + + $this->assertSame('given', $object->name); + $this->assertSame(5, $object->count); + $this->assertSame(0, ObjectWithPromotedDefaults::$constructorCalls); + } + #[Test] public function built_in_casters(): void {