Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e3bed4b
fix(event): use MapFrom attribute reference in MapFromListener
joelwurtz Jul 17, 2026
1b6f8ac
fix(generator): do not run transformation on null source value for no…
joelwurtz Jul 17, 2026
9094ddf
fix(transformer): create backed enum from scalar source value
joelwurtz Jul 17, 2026
8a8f85b
fix(transformer): convert between different backed enums using backin…
joelwurtz Jul 17, 2026
b0e70ba
fix(metadata): normalize leading backslash in class names from docblocks
joelwurtz Jul 17, 2026
5288a9c
fix(transformer): implement CheckTypeInterface so union branches are …
joelwurtz Jul 17, 2026
2c18dab
fix(transformer): unwrap nullable doctrine collection target to map i…
joelwurtz Jul 17, 2026
021cfb1
fix(generator): capture context in isAllowedAttribute closure for Map…
joelwurtz Jul 17, 2026
4a7b2e8
fix(extractor): guard nested property accessors against missing paren…
joelwurtz Jul 17, 2026
51caafe
fix(generator): support variadic constructor parameters
joelwurtz Jul 17, 2026
5763a97
fix(event): highest priority Mapper attribute wins as documented
joelwurtz Jul 17, 2026
e9c2967
fix(generator): correct identifier hashing for scalar and object iden…
joelwurtz Jul 17, 2026
9b2f49e
fix(loader): atomic mapper file writes and locked registry updates
joelwurtz Jul 17, 2026
019a524
fix(lazy): initialize LazyMap only once
joelwurtz Jul 17, 2026
954288c
fix(object-mapper): align Map if condition semantics with symfony/obj…
joelwurtz Jul 17, 2026
d80fa0c
fix(bundle): do not register cache warmer with eval loader
joelwurtz Jul 17, 2026
b4e06f1
fix(doctrine): detect doctrine entities even when metadata is not loa…
joelwurtz Jul 17, 2026
5293a94
fix(extractor): do not map static properties
joelwurtz Jul 17, 2026
9cb719a
chore(changelog): document review fixes and correct swapped release d…
joelwurtz Jul 17, 2026
f5a898e
fix(transformer): keep concrete uid subclass when copying symfony uid
joelwurtz Jul 21, 2026
08570d2
fix(transformer): keep concrete date time subclass in date time to da…
joelwurtz Jul 21, 2026
17342eb
fix(transformer): guard single matching union branch at runtime
joelwurtz Jul 21, 2026
87e52a0
fix(normalizer): decline value objects handled by dedicated symfony n…
joelwurtz Jul 21, 2026
0c7e647
fix(apiplatform): use computed resource class instead of resolving fr…
joelwurtz Jul 21, 2026
2e80133
fix(apiplatform): only shorten resource to id for array or mixed targets
joelwurtz Jul 21, 2026
1966352
fix(event): allow final and static public getters with MapToContext
joelwurtz Jul 21, 2026
7757ccc
fix(bundle): run object mapper listeners after doctrine and discrimin…
joelwurtz Jul 21, 2026
0af4897
chore(changelog): document medium review fixes
joelwurtz Jul 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,43 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

## [10.2.0] - 2026-27-04
### Fixed
- Use the `MapFrom` attribute reference instead of `MapTo` when resolving transformers in `MapFromListener`
- Do not run a transformation on a null source value when the target is not nullable, a `TypeError` is thrown for typed properties instead of the transformation crashing on the null value
- Create backed enum from scalar source value instead of assigning the raw scalar
- Convert between different backed enums through their backing value instead of copying the source instance
- Normalize leading backslash in class names resolved from docblocks, fixing a parse error in generated mappers for global namespace classes and the `__PM__` proxy name off-by-one
- Implement `CheckTypeInterface` on enum, date-time and uid transformers so union source types generate a runtime check for every branch
- Unwrap nullable doctrine collection target type, items are mapped to the collection value type instead of plain arrays
- Capture the context in the `isAllowedAttribute` closure, fixing `MapToContext` combined with `skip_null_values`
- Guard nested property accessors (`parent.child`) against null or uninitialized parent values on read, write and constructor paths, support private nested leaf properties and nullable parent types
- Support variadic constructor parameters, values are spread as individual arguments and an absent variadic no longer throws `MissingConstructorArgumentsException`
- The highest priority `Mapper` attribute wins as documented, instead of the lowest
- Correct identifier hashing: scalar identifiers are cast to string, object identifiers are hashed through their own mapper and `hash_final` no longer receives a string as its boolean argument, fixing mappers generated with `strictTypes: true`
- Atomic mapper file writes and locked registry updates in `FileLoader`, fixing partially written files and lost registry entries under concurrency, the cache warmer no longer discards previously registered mapper hashes
- Initialize `LazyMap` only once instead of re-running the mapping on every access
- Align `Map` attribute `if` condition semantics with symfony/object-mapper: `if: false` never maps the property, callables receive the value first and class level `Map` targets also match child classes
- Do not register the cache warmer when the `eval` loader is enabled, fixing container compilation with `automapper.loader.eval: true`
- Detect doctrine entities even when their metadata is not loaded yet, fixing missing provider and identifier on cold metadata factories
- Do not map static properties
- Keep the concrete Symfony Uid subclass (e.g. `UuidV4`) when copying a uid instead of building a base `Uuid`/`Ulid`
- Keep the concrete date time subclass in date time to date time mapping instead of always building `DateTime`/`DateTimeImmutable`
- Guard a single matching union branch at runtime so a value of an unhandled union member is not blindly transformed
- The `AutoMapperNormalizer` no longer claims value objects handled by dedicated symfony normalizers (`DateTimeInterface`, `BackedEnum`, `AbstractUid`, etc.), which could produce a structure dump instead of the expected representation
- Use the computed resource class in the API Platform JSON-LD context transformer instead of resolving it from a non-resource source (operator precedence fix)
- Only shorten an API Platform resource to its id for `array` or `mixed` targets, avoiding a `TypeError` on scalar targets
- Allow `final` and `static` public getters to be used with `#[MapToContext]`
- Run the object mapper listeners after the Doctrine and discriminator listeners in the bundle, so a `Map`-attributed source class keeps its Doctrine provider and discriminator handling

## [10.2.0] - 2026-04-27
### Added
- [GH#336](https://github.com/jolicode/automapper/pull/336) Support for array shape

### Fixed
- [GH#340](https://github.com/jolicode/automapper/pull/340) Allow type resolver in 2.0 for dependencies
- [GH#341](https://github.com/jolicode/automapper/pull/341) Fix phpstan extractor not using private properties in bundle

## [10.1.0] - 2026-18-03
## [10.1.0] - 2026-03-18
### Added
- [GH#328](https://github.com/jolicode/automapper/pull/328) Register mappers as lazy to reduce initialization calls when sub mappers are not used

Expand All @@ -28,18 +55,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- [GH#331](https://github.com/jolicode/automapper/pull/331) Fix syntax error in supports method return statement example
- [GH#329](https://github.com/jolicode/automapper/pull/329) Fix some typos

## [10.0.3] - 2025-25-02
## [10.0.3] - 2025-02-25
- [GH#327](https://github.com/jolicode/automapper/pull/327) Fix cache extractor in symfony bundle being mixed between source and target.

## [10.0.2] - 2025-24-02
## [10.0.2] - 2025-02-24
### Fixed
- [GH#326](https://github.com/jolicode/automapper/pull/326) Fix array, be consistent with old behavior, undefined array should be mapped with their keys.

## [10.0.1] - 2025-24-02
## [10.0.1] - 2025-02-24
### Fixed
- [GH#325](https://github.com/jolicode/automapper/pull/325) Fix creating union or intersection type when not enough types.

## [10.0.0] - 2025-10-02
## [10.0.0] - 2025-02-10
### Added
- [GH#297](https://github.com/jolicode/automapper/pull/297) Support PHP 8.5 and Symfony 8, this library now use the `TypeInfo` Component for types instead of PropertyInfo directly.
- [GH#297](https://github.com/jolicode/automapper/pull/297) Debug command now show the type of each property mapped, transformers will also display more information.
Expand Down
12 changes: 0 additions & 12 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,3 @@ parameters:
identifier: function.alreadyNarrowedType
count: 1
path: src/Generator/PropertyConditionsGenerator.php

-
message: '#^Method AutoMapper\\Loader\\FileLoader\:\:getRegistry\(\) should return array\<class\-string, string\> but returns array\.$#'
identifier: return.type
count: 1
path: src/Loader/FileLoader.php

-
message: '#^Property AutoMapper\\Loader\\FileLoader\:\:\$registry \(array\<class\-string, string\>\) does not accept mixed\.$#'
identifier: assign.propertyType
count: 1
path: src/Loader/FileLoader.php
3 changes: 2 additions & 1 deletion src/EventListener/Doctrine/DoctrineIdentifierListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ public function __construct(

public function __invoke(PropertyMetadataEvent $event): void
{
if ($event->mapperMetadata->target === 'array' || !$this->objectManager->getMetadataFactory()->hasMetadataFor($event->mapperMetadata->target)) {
// isTransient loads the metadata when needed, unlike hasMetadataFor which only checks already loaded ones
if ($event->mapperMetadata->target === 'array' || $this->objectManager->getMetadataFactory()->isTransient($event->mapperMetadata->target)) {
return;
}

Expand Down
3 changes: 2 additions & 1 deletion src/EventListener/Doctrine/DoctrineProviderListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public function __construct(

public function __invoke(GenerateMapperEvent $event): void
{
if ($event->mapperMetadata->target === 'array' || !$this->objectManager->getMetadataFactory()->hasMetadataFor($event->mapperMetadata->target)) {
// isTransient loads the metadata when needed, unlike hasMetadataFor which only checks already loaded ones
if ($event->mapperMetadata->target === 'array' || $this->objectManager->getMetadataFactory()->isTransient($event->mapperMetadata->target)) {
return;
}

Expand Down
7 changes: 3 additions & 4 deletions src/EventListener/MapFromListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
namespace AutoMapper\EventListener;

use AutoMapper\Attribute\MapFrom;
use AutoMapper\Attribute\MapTo;
use AutoMapper\AttributeReference\Reference;
use AutoMapper\Event\GenerateMapperEvent;
use AutoMapper\Event\PropertyMetadataEvent;
Expand Down Expand Up @@ -48,13 +47,13 @@ public function __invoke(GenerateMapperEvent $event): void
}

$property = $mapFromAttributeInstance->property;
$reference = new Reference(MapTo::class, $index, $reflectionClassOrPropertyOrMethod->getName());
$reference = new Reference(MapFrom::class, $index, $reflectionClassOrPropertyOrMethod->getName());
} elseif ($reflectionClassOrPropertyOrMethod instanceof \ReflectionMethod) {
$property = $this->getPropertyName($reflectionClassOrPropertyOrMethod->getName(), $properties);
$reference = new Reference(MapTo::class, $index, $reflectionClassOrPropertyOrMethod->getDeclaringClass()->getName(), methodName: $reflectionClassOrPropertyOrMethod->getName());
$reference = new Reference(MapFrom::class, $index, $reflectionClassOrPropertyOrMethod->getDeclaringClass()->getName(), methodName: $reflectionClassOrPropertyOrMethod->getName());
} else {
$property = $reflectionClassOrPropertyOrMethod->getName();
$reference = new Reference(MapTo::class, $index, $reflectionClassOrPropertyOrMethod->getDeclaringClass()->getName(), propertyName: $reflectionClassOrPropertyOrMethod->getName());
$reference = new Reference(MapFrom::class, $index, $reflectionClassOrPropertyOrMethod->getDeclaringClass()->getName(), propertyName: $reflectionClassOrPropertyOrMethod->getName());
}

if (null === $property) {
Expand Down
3 changes: 2 additions & 1 deletion src/EventListener/MapToContextListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public function __invoke(PropertyMetadataEvent $event): void

$reflectionMethod = $reflectionClass->getMethod($methodName);

if ($reflectionMethod->getModifiers() !== \ReflectionMethod::IS_PUBLIC) {
// getModifiers() is a bitmask, comparing it to IS_PUBLIC would reject final or static public methods
if (!$reflectionMethod->isPublic()) {
continue;
}

Expand Down
4 changes: 2 additions & 2 deletions src/EventListener/MapperListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ private function getMapperAttribute(GenerateMapperEvent $event, bool $allowParen
return [null, false, false];
}

// sort by priority
usort($mappers, static fn (array $a, array $b) => $a[0]->priority <=> $b[0]->priority);
// sort by priority, the highest priority wins
usort($mappers, static fn (array $a, array $b) => $b[0]->priority <=> $a[0]->priority);

return $mappers[0];
}
Expand Down
20 changes: 10 additions & 10 deletions src/EventListener/ObjectMapper/MapSourceListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public function __invoke(GenerateMapperEvent $event): void
$attribute = $sourceAttribute->newInstance();
$hasAnyMapAttribute = true;

if (!$attribute->target || $attribute->target === $event->mapperMetadata->target) {
if (!$attribute->target || $attribute->target === $event->mapperMetadata->target || is_subclass_of($event->mapperMetadata->target, $attribute->target)) {
$mapAttribute = $attribute;
break;
}
Expand Down Expand Up @@ -59,9 +59,11 @@ public function __invoke(GenerateMapperEvent $event): void
transformer: $this->getTransformerFromMapAttribute($event->mapperMetadata->sourceReflectionClass->getName(), $attribute, $reference, true),
);

$ifCallableName = null;

if ($attribute->if instanceof TargetClass) {
if (false === $attribute->if) {
// symfony/object-mapper never maps a property with `if: false`
$propertyMetadata->ignored = true;
$propertyMetadata->ignoreReason = 'Property is ignored by Map Attribute if condition';
} elseif ($attribute->if instanceof TargetClass) {
$reflectionObject = new \ReflectionClass($attribute->if);

if ($reflectionObject->hasProperty('className')) {
Expand Down Expand Up @@ -95,12 +97,10 @@ public function __invoke(GenerateMapperEvent $event): void
continue;
}
}
} elseif ($attribute->if && \is_callable($attribute->if, false, $ifCallableName)) {
if (\is_object($attribute->if)) {
$propertyMetadata->if = $reference;
} else {
$propertyMetadata->if = $ifCallableName;
}
} elseif ($attribute->if && \is_callable($attribute->if, false)) {
// symfony/object-mapper callables have their own calling convention, keep the
// attribute reference so the generated code can replicate it
$propertyMetadata->if = $reference;
} elseif (\is_string($attribute->if)) {
$propertyMetadata->if = $attribute->if;
}
Expand Down
14 changes: 9 additions & 5 deletions src/EventListener/ObjectMapper/MapTargetListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public function __invoke(GenerateMapperEvent $event): void
$attribute = $targetAttribute->newInstance();
$hasAnyMapAttribute = true;

if (!$attribute->source || $attribute->source === $event->mapperMetadata->source) {
if (!$attribute->source || $attribute->source === $event->mapperMetadata->source || is_subclass_of($event->mapperMetadata->source, $attribute->source)) {
$mapAttribute = $attribute;
}
}
Expand Down Expand Up @@ -57,10 +57,14 @@ public function __invoke(GenerateMapperEvent $event): void
transformer: $this->getTransformerFromMapAttribute($event->mapperMetadata->targetReflectionClass->getName(), $attribute, $reference, false),
);

$ifCallableName = null;

if ($attribute->if && \is_callable($attribute->if, false, $ifCallableName)) {
$propertyMetadata->if = $ifCallableName;
if (false === $attribute->if) {
// symfony/object-mapper never maps a property with `if: false`
$propertyMetadata->ignored = true;
$propertyMetadata->ignoreReason = 'Property is ignored by Map Attribute if condition';
} elseif ($attribute->if && \is_callable($attribute->if, false)) {
// symfony/object-mapper callables have their own calling convention, keep the
// attribute reference so the generated code can replicate it
$propertyMetadata->if = $reference;
} elseif (\is_string($attribute->if)) {
$propertyMetadata->if = $attribute->if;
}
Expand Down
6 changes: 3 additions & 3 deletions src/Extractor/ArrayReadAccessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public function getExpression(Expr $input, bool $target = false): Expr
return new Expr\ArrayDimFetch($input, new Scalar\String_($this->property));
}

public function getIsDefinedExpression(Expr\Variable $input, bool $nullable = false, bool $target = false): Expr
public function getIsDefinedExpression(Expr $input, bool $nullable = false, bool $target = false): Expr
{
if ($this->isArrayAccess) {
return new Expr\MethodCall($input, 'offsetExists', [new Arg(new Scalar\String_($this->property))]);
Expand All @@ -35,7 +35,7 @@ public function getIsDefinedExpression(Expr\Variable $input, bool $nullable = fa
return new Expr\FuncCall(new Name('array_key_exists'), [new Arg(new Scalar\String_($this->property)), new Arg($input)]);
}

public function getIsNullExpression(Expr\Variable $input, bool $target = false): Expr
public function getIsNullExpression(Expr $input, bool $target = false): Expr
{
/*
* Use the array dim fetch to read the value
Expand All @@ -45,7 +45,7 @@ public function getIsNullExpression(Expr\Variable $input, bool $target = false):
return new Expr\BinaryOp\LogicalAnd(new Expr\BooleanNot(new Expr\Isset_([new Expr\ArrayDimFetch($input, new Scalar\String_($this->property))])), new Expr\BinaryOp\Identical(new Expr\ConstFetch(new Name('null')), new Expr\ArrayDimFetch($input, new Scalar\String_($this->property))));
}

public function getIsUndefinedExpression(Expr\Variable $input, bool $target = false): Expr
public function getIsUndefinedExpression(Expr $input, bool $target = false): Expr
{
if ($this->isArrayAccess) {
return new Expr\BooleanNot(new Expr\MethodCall($input, 'offsetExists', [new Arg(new Scalar\String_($this->property))]));
Expand Down
21 changes: 20 additions & 1 deletion src/Extractor/MappingExtractor.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ public function getProperties(string $class, bool $withConstructorParameters = f

$properties = $this->propertyInfoExtractor->getProperties($class) ?? [];

// static properties are not part of an object state, they must not be mapped
$reflectionClass = new \ReflectionClass($class);
$properties = array_values(array_filter(
$properties,
static fn (string $property) => !($reflectionClass->hasProperty($property) && $reflectionClass->getProperty($property)->isStatic()),
));

if ($withConstructorParameters) {
$properties = array_values(
array_unique(
Expand Down Expand Up @@ -95,8 +102,15 @@ public function getReadAccessor(string $class, string $property, bool $allowExtr
$type = $this->sourceTypeExtractor->getType($class, $parent);
}

// a nullable parent wraps the real type, the nested accessor guards against the null value at runtime
if ($type instanceof Type\NullableType) {
$type = $type->getWrappedType();
}

$parentAccessor = $this->doGetReadAccessor($class, $parent, $allowExtraProperties);

$childClass = null;

if ($type instanceof Type\ObjectType) {
/** @var class-string $childClass */
$childClass = $type->getClassName();
Expand All @@ -111,7 +125,7 @@ public function getReadAccessor(string $class, string $property, bool $allowExtr
return null;
}

return new NestedReadAccessor($parentAccessor, $childAccessor);
return new NestedReadAccessor($parentAccessor, $childAccessor, $childClass, $property);
}

public function getWriteMutator(string $source, string $target, string $property, array $context = [], bool $allowExtraProperties = false): ?WriteMutatorInterface
Expand Down Expand Up @@ -139,6 +153,11 @@ public function getWriteMutator(string $source, string $target, string $property
return null;
}

// a nullable parent wraps the real type, the nested mutator guards against the null value at runtime
if ($lastAccessorType instanceof Type\NullableType) {
$lastAccessorType = $lastAccessorType->getWrappedType();
}

if ($lastAccessorType instanceof Type\ObjectType) {
/** @var class-string|'array' $targetClass */
$targetClass = $lastAccessorType->getClassName();
Expand Down
6 changes: 3 additions & 3 deletions src/Extractor/MethodReadAccessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,12 @@ public function getExpression(Expr $input, bool $target = false): Expr
return new Expr\MethodCall($input, $this->method, $methodCallArguments);
}

public function getIsDefinedExpression(Expr\Variable $input, bool $nullable = false, bool $target = false): ?Expr
public function getIsDefinedExpression(Expr $input, bool $nullable = false, bool $target = false): ?Expr
{
return null;
}

public function getIsNullExpression(Expr\Variable $input, bool $target = false): Expr
public function getIsNullExpression(Expr $input, bool $target = false): Expr
{
$methodCallExpr = $this->getExpression($input);

Expand All @@ -107,7 +107,7 @@ public function getIsNullExpression(Expr\Variable $input, bool $target = false):
);
}

public function getIsUndefinedExpression(Expr\Variable $input, bool $target = false): Expr
public function getIsUndefinedExpression(Expr $input, bool $target = false): Expr
{
return new Expr\ConstFetch(new Name('false'));
}
Expand Down
Loading
Loading