From d484c9adea63b5d573bf5a052c942c43099aa86f Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Wed, 16 Sep 2026 11:24:38 +0200 Subject: [PATCH 1/5] fix: append new group checking options instead of inserting them Inserted mid-constructor they silently shifted every following positional argument. --- src/Attribute/Mapper.php | 2 +- src/Configuration.php | 8 ++++---- src/Event/GenerateMapperEvent.php | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Attribute/Mapper.php b/src/Attribute/Mapper.php index aa2b9bec..289bfc30 100644 --- a/src/Attribute/Mapper.php +++ b/src/Attribute/Mapper.php @@ -25,7 +25,6 @@ public function __construct( public string|array|null $source = null, public string|array|null $target = null, public ?bool $checkAttributes = null, - public ?bool $checkGroups = null, public ?ConstructorStrategy $constructorStrategy = null, public ?bool $allowReadOnlyTargetToPopulate = null, public ?bool $strictTypes = null, @@ -34,6 +33,7 @@ public function __construct( public ?bool $allowExtraProperties = null, public ?Discriminator $discriminator = null, public ?bool $arrayLike = null, + public ?bool $checkGroups = null, ) { } } diff --git a/src/Configuration.php b/src/Configuration.php index d0d16700..a48ece06 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -25,10 +25,6 @@ public function __construct( * If the attributes should be checked to map the properties. */ public bool $attributeChecking = true, - /** - * Make mapper group aware, configure this to false when there is no groups behavior in your mapping, saving a lot of times. - */ - public bool $groupChecking = true, /** * If the mappers should be automatically generated if it does not exist * Otherwise the mapper will throw a MapperNotFoundException. @@ -62,6 +58,10 @@ public function __construct( * Enable this option to extract the type from the getter instead of the setter. */ public bool $extractTypesFromGetter = false, + /** + * Make mapper group aware, configure this to false when there is no groups behavior in your mapping, saving a lot of times. + */ + public bool $groupChecking = true, ) { } } diff --git a/src/Event/GenerateMapperEvent.php b/src/Event/GenerateMapperEvent.php index 9d7c9d2a..bab2f79f 100644 --- a/src/Event/GenerateMapperEvent.php +++ b/src/Event/GenerateMapperEvent.php @@ -23,7 +23,6 @@ public function __construct( public array $properties = [], public ?Provider $provider = null, public ?bool $checkAttributes = null, - public ?bool $checkGroups = null, public ?ConstructorStrategy $constructorStrategy = null, public ?bool $allowReadOnlyTargetToPopulate = null, public ?bool $strictTypes = null, @@ -34,6 +33,7 @@ public function __construct( public ?bool $sourceArrayLike = null, /** Whether the target is built as a keyed array shape rather than a typed object (null = infer). */ public ?bool $targetArrayLike = null, + public ?bool $checkGroups = null, ) { } } From 839e69bc8b6a744ba91f4f282327e8cefd42e4ac Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Wed, 16 Sep 2026 11:24:45 +0200 Subject: [PATCH 2/5] fix(json-streamer): delegate enums and uids to the fallback reader and writer EnumType extends ObjectType, so enums were claimed by the AutoMapper: read as null, written as {"name":"FLAT","value":"flat"}. Share the normalizer exclusion list. --- src/JsonStreamer/JsonStreamReader.php | 7 ++-- src/JsonStreamer/JsonStreamWriter.php | 12 +++---- src/Normalizer/AutoMapperNormalizer.php | 27 +++----------- src/ValueObjectTypes.php | 39 +++++++++++++++++++++ tests/JsonStreamer/JsonStreamReaderTest.php | 21 +++++++++++ tests/JsonStreamer/JsonStreamWriterTest.php | 19 ++++++++++ 6 files changed, 90 insertions(+), 35 deletions(-) create mode 100644 src/ValueObjectTypes.php diff --git a/src/JsonStreamer/JsonStreamReader.php b/src/JsonStreamer/JsonStreamReader.php index 4bd0e604..96c162ae 100644 --- a/src/JsonStreamer/JsonStreamReader.php +++ b/src/JsonStreamer/JsonStreamReader.php @@ -10,6 +10,7 @@ use AutoMapper\MapperContext; use AutoMapper\MapperInterface; use AutoMapper\Metadata\MetadataRegistry; +use AutoMapper\ValueObjectTypes; use Symfony\Component\JsonStreamer\StreamReaderInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -125,11 +126,7 @@ private function ownedClassName(Type $type): ?string /** @var class-string $className */ $className = $type->getClassName(); - if ( - is_a($className, \DateTimeInterface::class, true) - || is_a($className, \DateInterval::class, true) - || is_a($className, \DateTimeZone::class, true) - ) { + if (ValueObjectTypes::isUnsupported($className)) { return null; } diff --git a/src/JsonStreamer/JsonStreamWriter.php b/src/JsonStreamer/JsonStreamWriter.php index 4d69208e..7cdd0e42 100644 --- a/src/JsonStreamer/JsonStreamWriter.php +++ b/src/JsonStreamer/JsonStreamWriter.php @@ -8,6 +8,7 @@ use AutoMapper\AutoMapperRegistryInterface; use AutoMapper\MapperInterface; use AutoMapper\Metadata\MetadataRegistry; +use AutoMapper\ValueObjectTypes; use Symfony\Component\JsonStreamer\StreamWriterInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -57,9 +58,8 @@ public function write( } if ( - $unwrapped instanceof ObjectType - && \is_object($data) - && $unwrapped->getClassName() === $data::class + \is_object($data) + && $this->ownedClassName($unwrapped) === $data::class && ($mapper = $this->jsonMapper($data::class)) !== null ) { return $this->wrap(static fn (): iterable => $mapper->map($data, $options) ?? []); @@ -191,11 +191,7 @@ private function ownedClassName(Type $type): ?string /** @var class-string $className */ $className = $type->getClassName(); - if ( - is_a($className, \DateTimeInterface::class, true) - || is_a($className, \DateInterval::class, true) - || is_a($className, \DateTimeZone::class, true) - ) { + if (ValueObjectTypes::isUnsupported($className)) { return null; } diff --git a/src/Normalizer/AutoMapperNormalizer.php b/src/Normalizer/AutoMapperNormalizer.php index ec0b98ad..3690919e 100644 --- a/src/Normalizer/AutoMapperNormalizer.php +++ b/src/Normalizer/AutoMapperNormalizer.php @@ -9,6 +9,7 @@ use AutoMapper\Exception\MissingConstructorArgumentsException; use AutoMapper\MapperContext; use AutoMapper\Metadata\MetadataRegistry; +use AutoMapper\ValueObjectTypes; use Symfony\Component\Serializer\Exception\CircularReferenceException as SymfonyCircularReferenceException; use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException as SymfonyMissingConstructorArgumentsException; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; @@ -26,20 +27,6 @@ */ readonly class AutoMapperNormalizer implements NormalizerInterface, DenormalizerInterface { - /** - * Value object types handled by their own dedicated symfony normalizer, AutoMapper must not claim them - * otherwise it would produce a structure dump instead of e.g. an RFC3339 date string. - * - * @var list - */ - private const array UNSUPPORTED_TYPES = [ - \DateTimeInterface::class, - \DateTimeZone::class, - \DateInterval::class, - \UnitEnum::class, - \Symfony\Component\Uid\AbstractUid::class, - ]; - private const array SERIALIZER_CONTEXT_MAPPING = [ AbstractNormalizer::GROUPS => MapperContext::GROUPS, AbstractNormalizer::ATTRIBUTES => MapperContext::ALLOWED_ATTRIBUTES, @@ -106,10 +93,8 @@ public function supportsNormalization(mixed $data, ?string $format = null, array return false; } - foreach (self::UNSUPPORTED_TYPES as $unsupportedType) { - if ($data instanceof $unsupportedType) { - return false; - } + if (ValueObjectTypes::isUnsupported($data::class)) { + return false; } if ($this->onlyMetadataRegistry === null) { @@ -128,10 +113,8 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form return false; } - foreach (self::UNSUPPORTED_TYPES as $unsupportedType) { - if (is_a($type, $unsupportedType, true)) { - return false; - } + if (ValueObjectTypes::isUnsupported($type)) { + return false; } if ($this->onlyMetadataRegistry === null) { diff --git a/src/ValueObjectTypes.php b/src/ValueObjectTypes.php new file mode 100644 index 00000000..1707b70c --- /dev/null +++ b/src/ValueObjectTypes.php @@ -0,0 +1,39 @@ + */ + public const array UNSUPPORTED = [ + \DateTimeInterface::class, + \DateTimeZone::class, + \DateInterval::class, + \UnitEnum::class, + \Symfony\Component\Uid\AbstractUid::class, + ]; + + /** + * @param class-string $className + */ + public static function isUnsupported(string $className): bool + { + foreach (self::UNSUPPORTED as $unsupported) { + if (is_a($className, $unsupported, true)) { + return true; + } + } + + return false; + } +} diff --git a/tests/JsonStreamer/JsonStreamReaderTest.php b/tests/JsonStreamer/JsonStreamReaderTest.php index c51b2030..443e0151 100644 --- a/tests/JsonStreamer/JsonStreamReaderTest.php +++ b/tests/JsonStreamer/JsonStreamReaderTest.php @@ -178,4 +178,25 @@ classPrefix: 'JsonStreamReaderPrivate_', self::assertEquals($user, $user2); } + + public function testEnumIsDelegatedToTheFallbackReader(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper(classPrefix: 'JsonStreamReaderEnum_'); + $reader = new JsonStreamReader($autoMapper, FallbackJsonStreamReader::create()); + + self::assertSame( + Fixtures\AddressType::FLAT, + $reader->read($this->stream('"flat"'), Type::enum(Fixtures\AddressType::class)), + ); + + $list = $reader->read( + $this->stream('["flat","apartment"]'), + Type::list(Type::enum(Fixtures\AddressType::class)), + ); + + self::assertSame( + [Fixtures\AddressType::FLAT, Fixtures\AddressType::APARTMENT], + iterator_to_array($list), + ); + } } diff --git a/tests/JsonStreamer/JsonStreamWriterTest.php b/tests/JsonStreamer/JsonStreamWriterTest.php index b6f6a24f..97d938ee 100644 --- a/tests/JsonStreamer/JsonStreamWriterTest.php +++ b/tests/JsonStreamer/JsonStreamWriterTest.php @@ -109,4 +109,23 @@ classPrefix: 'JsonStreamerPrivate_' $user2 = $autoMapper->map($data, Fixtures\User::class); self::assertEquals($user, $user2); } + + public function testEnumIsDelegatedToTheFallbackWriter(): void + { + $autoMapper = AutoMapperBuilder::buildAutoMapper(classPrefix: 'JsonStreamWriterEnum_'); + $writer = new JsonStreamWriter($autoMapper, FallbackJsonStreamWriter::create()); + + self::assertSame( + '"flat"', + (string) $writer->write(Fixtures\AddressType::FLAT, Type::enum(Fixtures\AddressType::class)), + ); + + self::assertSame( + '["flat","apartment"]', + (string) $writer->write( + [Fixtures\AddressType::FLAT, Fixtures\AddressType::APARTMENT], + Type::list(Type::enum(Fixtures\AddressType::class)), + ), + ); + } } From c72dcd3c24cc90ba1bfb58a10d8edebc8e09fb82 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Wed, 16 Sep 2026 11:24:45 +0200 Subject: [PATCH 3/5] chore: fix export-ignore paths, tests/ was shipped in the dist archive --- .gitattributes | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index d88fae14..29dd86d1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,16 @@ -/Tests/ export-ignore +/tests/ export-ignore +/bench/ export-ignore +/docs/ export-ignore /.gitattributes export-ignore /.gitignore export-ignore +/.php-cs-fixer.php export-ignore +/castor.php export-ignore +/castor.composer.json export-ignore +/castor.composer.lock export-ignore +/mkdocs.yaml export-ignore +/phpstan.neon export-ignore /phpunit.xml export-ignore +/pyproject.toml export-ignore +/poetry.lock export-ignore +/bootstrap.php export-ignore +/.github/ export-ignore From 00a26aec9944983079ae73efa074493746234355 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Wed, 16 Sep 2026 11:24:45 +0200 Subject: [PATCH 4/5] docs: document the arrayLike mapper option --- docs/mapping/mapper-attribute.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/mapping/mapper-attribute.md b/docs/mapping/mapper-attribute.md index 9aa203ce..190a2d76 100644 --- a/docs/mapping/mapper-attribute.md +++ b/docs/mapping/mapper-attribute.md @@ -32,6 +32,26 @@ with a single attribute. The `#[Mapper]` attribute supports most of the configuration parameters specified in the [global configuration](../getting-started/configuration.md). It will override the global configuration for the specified mapping. +## Array-like classes + +By default a class is mapped as a typed object: its properties are discovered through reflection. +Set `arrayLike: true` to read or write it as a keyed array shape instead, where the keys are +dynamic and come from the other side of the mapping. This is what `array`, `stdClass` and `json` +already do implicitly, and it is the option to use for a class exposing arbitrary keys through +`ArrayAccess`. + +```php +#[Mapper(arrayLike: true)] +class Bag implements \ArrayAccess +{ + private array $values = []; + + // ... +} +``` + +Left to `null` the behaviour is inferred. + ## Register This attribute may also be used when registering mappers manually when using the Symfony bundle. From 5adf927e6bcff0805b48846cf21f2383f1ed6976 Mon Sep 17 00:00:00 2001 From: Joel Wurtz Date: Wed, 16 Sep 2026 11:24:45 +0200 Subject: [PATCH 5/5] chore(changelog): prepare 10.3.0 Also fixes the 10.0.x dates, restores the missing 9.5.1 entry and the compare links. --- CHANGELOG.md | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53b4eacc..26caa421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -### Feature +## [10.3.0] - 2026-09-16 + +### Added - Allow to disable group checking globally or per mapper, instead of only per property - Add a `symfony/json-streamer` integration: the AutoMapper can now read and write JSON streams, keeping all its mapping features available while streaming. Enable it in the bundle with the @@ -24,19 +26,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 resolved once during metadata discovery and reused everywhere, instead of being guessed from the source and target names +### Changed +These are bug fixes, but they change the output of mappings that already worked: + +- Convert between different backed enums through their backing value instead of copying the source instance +- 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` +- 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 +- The highest priority `Mapper` attribute wins as documented, instead of the lowest + ### Fixed - Resolve API Platform IRIs in every JSON format, not only JSON-LD, so a relation sent as an IRI on `application/json` or `application/merge-patch+json` no longer reaches the mapper as a raw string - 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 @@ -44,14 +53,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 +- The JsonStreamer integration no longer claims enums and uids, which were read as `null` and written as a structure dump (`{"name":"FLAT","value":"flat"}`) instead of being delegated to the Symfony reader and writer ## [10.2.0] - 2026-04-27 ### Added @@ -74,18 +81,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-02-25 +## [10.0.3] - 2026-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-02-24 +## [10.0.2] - 2026-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-02-24 +## [10.0.1] - 2026-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-02-10 +## [10.0.0] - 2026-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. @@ -110,6 +117,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Miscellaneous - [GH#297](https://github.com/jolicode/automapper/pull/297) Add a castor task to serve the symfony app in tests for debugging purpose. +## [9.5.1] - 2026-04-27 +### Fixed +- Allow type resolver deps in 2.0 + ## [9.5.0] - 2025-09-18 ### Added - [GH#260](https://github.com/jolicode/automapper/pull/260) Add support for identifiers detection and comparison of objects, this allow mappers to detect if objects are equals based on some properties, which allow better deep merge / update of collections. @@ -525,12 +536,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed * [AutoMapper] [GH#179](https://github.com/janephp/janephp/pull/179) Fixing incompatible changes in Symfony 5.0 -[Unreleased]: https://github.com/jolicode/automapper/compare/10.1.0...HEAD +[10.3.0]: https://github.com/jolicode/automapper/compare/10.2.0...10.3.0 +[10.2.0]: https://github.com/jolicode/automapper/compare/10.1.0...10.2.0 [10.1.0]: https://github.com/jolicode/automapper/compare/10.0.3...10.1.0 [10.0.3]: https://github.com/jolicode/automapper/compare/10.0.2...10.0.3 [10.0.2]: https://github.com/jolicode/automapper/compare/10.0.1...10.0.2 [10.0.1]: https://github.com/jolicode/automapper/compare/10.0.0...10.0.1 [10.0.0]: https://github.com/jolicode/automapper/compare/9.5.0...10.0.0 +[9.5.1]: https://github.com/jolicode/automapper/compare/9.5.0...9.5.1 [9.5.0]: https://github.com/janephp/janephp/compare/9.4.1...9.5.0 [9.4.1]: https://github.com/janephp/janephp/compare/9.4.0...9.4.1 [9.4.0]: https://github.com/janephp/janephp/compare/9.3.1...9.4.0