Skip to content

Conversation

@Nayte91
Copy link
Contributor

@Nayte91 Nayte91 commented Nov 13, 2025

Q A
Branch? 8.1
Bug fix? no
New feature? yes
Deprecations? no
Issues Fix #61044
License MIT

Enable the object mapper to convert backed enums (int or string) to their scalar values.
Examples: complete list:

  • int → int-backed Enum
  • string → string-backed Enum
  • string-backed Enum → string
  • int-backed Enum → int
  • enum → same enum (unchanged behavior)

It still throws an exception if you try another combination.

For now, you have to create 2 Transformers to achieve this:

/** @implements TransformCallableInterface<object, object> */
class ScalarToEnumMapper implements TransformCallableInterface
{
    public function __invoke(mixed $value, object $source, ?object $target): mixed
    {
        if (null === $value) {
            return null;
        }

        return $value->value ?? $value->name;
    }
}

/** @implements TransformCallableInterface<object, object> */
class EnumToScalarMapper implements TransformCallableInterface
{
    public function __invoke(mixed $value, object $source, ?object $target): mixed
    {
        if (null === $value) {
            return null;
        }

        if (is_string($value)) {
            return $value;
        }

        if ($value instanceof \BackedEnum) {
            return $value->value;
        }

        if ($value instanceof \UnitEnum) {
            return $value->name;
        }

        return null;
    }
}

This PR aims to replace those out of the box. This is a common use case, for example if you use Symfony Messenger with an Async worker, and you have to send Commands with serialized values:

class CreatePost // This is the source!
{
    public function __construct(
        public ?string $content = null,
        #[Map(transform: ScalarToEnumMapper::class)]
        public ?string $status = null, // You will need to convert it as Status in your Post Entity
    }
}

Or, the other way around, if you want to get the scalar versions of your Enums to simplify code in templates for your front devs.

class PostCard // This is the target!
{
    public function __construct(
        public ?string $title= null,
        #[Map(transform: EnumToScalarMapper::class)]
        public ?string $contentType = null, // You will want to display only the Enum.value in Twig
    }
}

Now, you just map 2 objects that has the same named property, and Object-Mapper will try to gracefully convert Enum to scalar or scalar to Enum based on their type!

@carsonbot carsonbot added this to the 7.4 milestone Nov 13, 2025
@carsonbot carsonbot changed the title Feat 61044/enum mapping Feat 61044/enum mapping Nov 13, 2025
@Nayte91 Nayte91 force-pushed the feat-61044/enum-mapping branch 3 times, most recently from bbda5b2 to 97e9a78 Compare November 13, 2025 23:52
@carsonbot carsonbot changed the title Feat 61044/enum mapping [ObjectMapper] Feat 61044/enum mapping Nov 14, 2025
Copy link
Member

@nicolas-grekas nicolas-grekas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering: can't this concern be addressed with a transformer?

@Nayte91
Copy link
Contributor Author

Nayte91 commented Nov 14, 2025

Just wondering: can't this concern be addressed with a transformer?

Absolutely! And this PR aims to get rid of them, as it can be a common use case. Instead of writing everything in this block, I answered you in the main post.

@Nayte91 Nayte91 force-pushed the feat-61044/enum-mapping branch from 97e9a78 to 2b703ab Compare November 14, 2025 09:34
@stof
Copy link
Member

stof commented Nov 14, 2025

Transforming a UnitEnum to its name is a no-go to me. The case name is not a canonical scalar representation of an enum.

@Nayte91
Copy link
Contributor Author

Nayte91 commented Nov 14, 2025

Transforming a UnitEnum to its name is a no-go to me. The case name is not a canonical scalar representation of an enum.

For me neither, and I think this doesn't happen in my proposition? Tests throws error when you try to refer to the name instead of value. Did you see any line that uses name instead of value somewhere? I'm checking again!

@Nayte91 Nayte91 force-pushed the feat-61044/enum-mapping branch from 2b703ab to b7a075d Compare November 14, 2025 15:29
@Nayte91
Copy link
Contributor Author

Nayte91 commented Nov 14, 2025

Ok few modifications;

  • Stof was right, I found a case where Enum mapped into name instead of value, because of reflection! I modified this behavior and ajusted tests (avoiding case Active = 'Active'...) to snipe that,
  • Made the DTO a lot simpler and a lot less, because the point to fight with attributes or property names,
  • More test cases,
  • Better error messages,
  • Bad Enum mappings now throw the MappingTransformException,
  • Better performances by early returning when no backed-Enums are involved in mapping.

I have some questions:

  1. How about a specific EnumTests class?
  2. How about a specific EnumMapper class?
  3. How about a specific Exception, if MappingTransformException seems not relevant for you?
  4. There's a test about "Enum to Enum" mapping; Maybe it's a bit overkill?

I'm pretty happy with implementation here, beside those questions; SRP seems good (in methods, not in classes), exceptions and messages seems useful, tests covers a lot, style seems clean, performances are saved. I will not work on it anymore until your feedback, roast me boyz!

@Nayte91 Nayte91 force-pushed the feat-61044/enum-mapping branch 6 times, most recently from c95e577 to 1895c59 Compare November 15, 2025 16:41
@nicolas-grekas nicolas-grekas modified the milestones: 7.4, 8.1 Nov 16, 2025
@Nayte91 Nayte91 force-pushed the feat-61044/enum-mapping branch from 1895c59 to cecf816 Compare November 17, 2025 15:18
Copy link
Contributor

@soyuka soyuka left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion on the approach:

  • append to the ReflectionObjectMapperMetadataFactory (or create a new ObjectMapperMetadataFactory)
  • this one detects whether we need a transformation and appends a transform to the Mapping
  • move enum transformation code to transformers

This way we can have less specific code in the object mapper class (also helps when we want to continue #61515 )

@Nayte91 Nayte91 force-pushed the feat-61044/enum-mapping branch 2 times, most recently from 58a0bfa to 19ca970 Compare November 25, 2025 13:38
…flectionObjectMapperMetadataFactory (Option A)

This approach extends ReflectionObjectMapperMetadataFactory to detect
enum conversion needs and inject MapEnum transformer automatically.

Changes:
- Add Transform/MapEnum.php: Handles BackedEnum <-> scalar conversions
- Modify ReflectionObjectMapperMetadataFactory: Detects enum types via
  context['target_refl'] and enriches Mapping with MapEnum transformer
- Modify ObjectMapper: Passes target_refl context when fetching metadata

Pros:
- Single factory handles all metadata enrichment
- No additional factories

Cons:
- Mixes reflection concerns with enum-specific logic
- Factory becomes more complex
@Nayte91 Nayte91 force-pushed the feat-61044/enum-mapping branch from 19ca970 to 5725a54 Compare November 25, 2025 15:42
@Nayte91
Copy link
Contributor Author

Nayte91 commented Nov 25, 2025

suggestion on the approach:

  • append to the ReflectionObjectMapperMetadataFactory (or create a new ObjectMapperMetadataFactory)
  • this one detects whether we need a transformation and appends a transform to the Mapping
  • move enum transformation code to transformers

This way we can have less specific code in the object mapper class (also helps when we want to continue #61515 )

I changed the PR to something with your approach; for now, no details, no tests, just to confirm the direction?

Comment on lines +134 to +147
private function detectEnumTransformer(string $sourceTypeName, string $targetTypeName): ?MapEnum
{
// BackedEnum -> scalar (int or string)
if (is_a($sourceTypeName, \BackedEnum::class, true) && \in_array($targetTypeName, ['int', 'string'], true)) {
return new MapEnum($targetTypeName);
}

// scalar -> BackedEnum
if (\in_array($sourceTypeName, ['int', 'string'], true) && is_a($targetTypeName, \BackedEnum::class, true)) {
return new MapEnum($targetTypeName);
}

return null;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using sdtClass as the $source value

$readMetadataFrom = $source;
$refl = $this->getSourceReflectionClass($source) ?? $targetRefl;
// When source contains no metadata, we read metadata on the target instead
if ($refl === $targetRefl) {
$readMetadataFrom = $mappedTarget;
}

will produce a $readMetadataFrom that is the same as $targetRefl.

In this case, $sourceTypeName and $targetTypeName will be equal, so neither of the two conditions will be applied

Maybe for this we should do something like?

if ($sourceTypeName === $targetTypeName && is_a($targetTypeName, \BackedEnum::class, true)) {
    return new MapEnum($targetTypeName);
}

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] Enum mapping process

7 participants