@@ -117,6 +117,12 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer
117117 */
118118 public const PRESERVE_EMPTY_OBJECTS = 'preserve_empty_objects ' ;
119119
120+ /**
121+ * When true, elements of scalar collections (int[], float[], bool[], string[])
122+ * are safely cast to their target builtin type during denormalization.
123+ */
124+ public const CAST_SCALAR_COLLECTIONS = 'cast_scalar_collections ' ;
125+
120126 protected ?ClassDiscriminatorResolverInterface $ classDiscriminatorResolver ;
121127
122128 /**
@@ -537,6 +543,19 @@ private function validateAndDenormalize(Type $type, string $currentClass, string
537543 $ class = $ collectionValueBaseType ->getClassName ().'[] ' ;
538544 $ context ['key_type ' ] = $ collectionKeyType ;
539545 $ context ['value_type ' ] = $ collectionValueType ;
546+ } elseif (
547+ !empty ($ context [self ::CAST_SCALAR_COLLECTIONS ])
548+ && $ type instanceof CollectionType
549+ && $ type ->isList ()
550+ && $ collectionValueBaseType instanceof BuiltinType
551+ && \is_array ($ data )
552+ ) {
553+ $ data = $ this ->castScalarCollectionElements (
554+ $ data ,
555+ $ collectionValueType , // original (possibly wrapped) value type
556+ $ collectionValueBaseType , // unwrapped BuiltinType: int/float/bool/string
557+ (bool ) ($ context [self ::DISABLE_TYPE_ENFORCEMENT ] ?? false ) // loose mode
558+ );
540559 } elseif ($ collectionValueBaseType instanceof BuiltinType && TypeIdentifier::ARRAY === $ collectionValueBaseType ->getTypeIdentifier ()) {
541560 // get inner type for any nested array
542561 $ innerType = $ collectionValueType ;
@@ -927,4 +946,129 @@ private function getMappedClass(array $data, string $class, array $context): str
927946
928947 return $ mappedClass ;
929948 }
949+
950+ /**
951+ * Cast each element of a scalar collection (int/float/bool/string) according to the element type.
952+ *
953+ * @param array<int|string,mixed> $data
954+ *
955+ * @return array<int|string,mixed>
956+ */
957+ private function castScalarCollectionElements (
958+ array $ data ,
959+ Type $ valueType ,
960+ BuiltinType $ baseType ,
961+ bool $ isDisabledTypeEnforcement ,
962+ ): array {
963+ $ nullable = false ; // Determine element nullability by unwrapping NullableType/WrappingTypeInterface layers
964+ $ candidateType = $ valueType ;
965+ while ($ candidateType instanceof WrappingTypeInterface) {
966+ if ($ candidateType instanceof NullableType) {
967+ $ nullable = true ;
968+ break ;
969+ }
970+ $ candidateType = $ candidateType ->getWrappedType ();
971+ }
972+ $ collectionItemTypeIdentifier = $ baseType ->getTypeIdentifier (); // INT/FLOAT/BOOL/STRING
973+ // Cast function preserves array keys to avoid breaking associative collections
974+ $ cast = static function (mixed $ v ) use (
975+ $ collectionItemTypeIdentifier ,
976+ $ nullable ,
977+ $ isDisabledTypeEnforcement
978+ ) {
979+ // Null handling
980+ if (null === $ v ) {
981+ if ($ nullable || $ isDisabledTypeEnforcement ) {
982+ return null ;
983+ }
984+
985+ throw NotNormalizableValueException::createForUnexpectedDataType ('Null is not allowed for a non-nullable collection element. ' , $ v , [$ collectionItemTypeIdentifier ->name ]);
986+ }
987+
988+ switch ($ collectionItemTypeIdentifier ) {
989+ case TypeIdentifier::INT :
990+ if (\is_int ($ v )) {
991+ return $ v ;
992+ }
993+ // Strict, safe integer-string (no spaces, no decimals, optional sign)
994+ if (\is_string ($ v ) && preg_match ('/^[+-]?\d+$/ ' , $ v )) {
995+ return (int ) $ v ;
996+ }
997+ // Non-strict: allow scalar coercion only (avoid arrays/objects)
998+ if ($ isDisabledTypeEnforcement && \is_scalar ($ v )) {
999+ return (int ) $ v ; // e.g. "12abc"→12, true→1, 3.7→3, "abc"→0
1000+ }
1001+ break ;
1002+
1003+ case TypeIdentifier::FLOAT :
1004+ if (\is_float ($ v )) {
1005+ return $ v ;
1006+ }
1007+ if (\is_int ($ v )) {
1008+ return (float ) $ v ;
1009+ }
1010+ // Strict: numeric strings (accepts "1", "2.5", "-0.75", "1e3")
1011+ if (\is_string ($ v ) && is_numeric ($ v )) {
1012+ return (float ) $ v ;
1013+ }
1014+ // Non-strict: allow scalar coercion only
1015+ if ($ isDisabledTypeEnforcement && \is_scalar ($ v )) {
1016+ return (float ) $ v ; // e.g. "abc"→0.0, true→1.0
1017+ }
1018+ break ;
1019+
1020+ case TypeIdentifier::BOOL :
1021+ // Accept real booleans in any mode
1022+ if (\is_bool ($ v )) {
1023+ return $ v ;
1024+ }
1025+
1026+ // Accept int 0/1 even in strict mode
1027+ if (0 === $ v || 1 === $ v ) {
1028+ return (bool ) $ v ;
1029+ }
1030+
1031+ // Non-strict mode: allow common string synonyms and scalar coercion
1032+ if ($ isDisabledTypeEnforcement ) {
1033+ if (\is_string ($ v )) {
1034+ $ v = strtolower (trim ($ v ));
1035+ if (\in_array ($ v , ['1 ' , 'true ' , 'on ' , 'yes ' ], true )) {
1036+ return true ;
1037+ }
1038+ if (\in_array ($ v , ['0 ' , 'false ' , 'off ' , 'no ' ], true )) {
1039+ return false ;
1040+ }
1041+ }
1042+ // Last-chance scalar coercion (e.g., "abc"->true, ""->false)
1043+ if (\is_scalar ($ v )) {
1044+ return (bool ) $ v ;
1045+ }
1046+
1047+ // Arrays/objects remain invalid even in non-strict mode
1048+ throw NotNormalizableValueException::createForUnexpectedDataType ('Cannot coerce non-scalar value to bool. ' , $ v , ['bool ' , 'int ' , 'string ' ]);
1049+ }
1050+ break ;
1051+
1052+ case TypeIdentifier::STRING :
1053+ if (\is_string ($ v )) {
1054+ return $ v ;
1055+ }
1056+ if (!$ isDisabledTypeEnforcement ) {
1057+ break ;
1058+ }
1059+ if (\is_scalar ($ v )) {
1060+ return (string ) $ v ;
1061+ }
1062+ break ;
1063+ }
1064+
1065+ throw NotNormalizableValueException::createForUnexpectedDataType ('Failed to denormalize scalar collection element. ' , $ v , [$ collectionItemTypeIdentifier ->name ]);
1066+ };
1067+
1068+ foreach ($ data as $ k => $ elm ) {
1069+ $ data [$ k ] = $ cast ($ elm ); // preserve keys
1070+ }
1071+
1072+ return $ data ;
1073+ }
9301074}
0 commit comments