@@ -118,6 +118,12 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer
118118 */
119119 public const PRESERVE_EMPTY_OBJECTS = 'preserve_empty_objects ' ;
120120
121+ /**
122+ * When true, elements of scalar collections (int[], float[], bool[], string[])
123+ * are safely cast to their target builtin type during denormalization.
124+ */
125+ public const CAST_SCALAR_COLLECTIONS = 'cast_scalar_collections ' ;
126+
121127 protected ?ClassDiscriminatorResolverInterface $ classDiscriminatorResolver ;
122128
123129 /**
@@ -533,6 +539,37 @@ private function validateAndDenormalizeLegacy(array $types, string $currentClass
533539 }
534540
535541 $ context ['value_type ' ] = $ collectionValueType ;
542+ } elseif (
543+ !empty ($ context [self ::CAST_SCALAR_COLLECTIONS ]) // opt-in via context flag
544+ && $ type ->isCollection ()
545+ && \count ($ collectionValueType = $ type ->getCollectionValueTypes ()) > 0
546+ && \in_array (
547+ $ collectionValueType [0 ]->getBuiltinType (),
548+ [
549+ LegacyType::BUILTIN_TYPE_INT ,
550+ LegacyType::BUILTIN_TYPE_FLOAT ,
551+ LegacyType::BUILTIN_TYPE_BOOL ,
552+ LegacyType::BUILTIN_TYPE_STRING ,
553+ ],
554+ true
555+ )
556+ && \is_array ($ data )
557+ && (\function_exists ('array_is_list ' ) ? \array_is_list ($ data ) : (array_keys ($ data ) === range (0 , \count ($ data ) - 1 )))
558+ ) {
559+ // Map legacy builtin to the new BuiltinType used by castScalarCollectionElements()
560+ $ baseType = match ($ collectionValueType [0 ]->getBuiltinType ()) {
561+ LegacyType::BUILTIN_TYPE_INT => Type::int (), // BuiltinType<int>
562+ LegacyType::BUILTIN_TYPE_FLOAT => Type::float (), // BuiltinType<float>
563+ LegacyType::BUILTIN_TYPE_BOOL => Type::bool (), // BuiltinType<bool>
564+ LegacyType::BUILTIN_TYPE_STRING => Type::string (),// BuiltinType<string>
565+ default => Type::mixed (),
566+ };
567+ $ data = $ this ->castScalarCollectionElements (
568+ $ data ,
569+ $ baseType , // original (legacy path: equals base)
570+ $ baseType , // unwrapped BuiltinType: int/float/bool/string
571+ (bool ) ($ context [self ::DISABLE_TYPE_ENFORCEMENT ] ?? false ) // loose mode
572+ );
536573 } elseif ($ type ->isCollection () && \count ($ collectionValueType = $ type ->getCollectionValueTypes ()) > 0 && LegacyType::BUILTIN_TYPE_ARRAY === $ collectionValueType [0 ]->getBuiltinType ()) {
537574 // get inner type for any nested array
538575 [$ innerType ] = $ collectionValueType ;
@@ -773,7 +810,6 @@ private function validateAndDenormalize(Type $type, string $currentClass, string
773810 return (int ) $ data ;
774811 }
775812 }
776-
777813 if ($ collectionValueType ) {
778814 try {
779815 $ collectionValueBaseType = $ collectionValueType ;
@@ -795,6 +831,19 @@ private function validateAndDenormalize(Type $type, string $currentClass, string
795831 $ class = $ collectionValueBaseType ->getClassName ().'[] ' ;
796832 $ context ['key_type ' ] = $ collectionKeyType ;
797833 $ context ['value_type ' ] = $ collectionValueType ;
834+ } elseif (
835+ !empty ($ context [self ::CAST_SCALAR_COLLECTIONS ])
836+ && $ type instanceof CollectionType
837+ && $ type ->isList ()
838+ && $ collectionValueBaseType instanceof BuiltinType
839+ && \is_array ($ data )
840+ ) {
841+ $ data = $ this ->castScalarCollectionElements (
842+ $ data ,
843+ $ collectionValueType , // original (possibly wrapped) value type
844+ $ collectionValueBaseType , // unwrapped BuiltinType: int/float/bool/string
845+ (bool ) ($ context [self ::DISABLE_TYPE_ENFORCEMENT ] ?? false ) // loose mode
846+ );
798847 } elseif (
799848 // BC layer for type-info < 7.2
800849 !class_exists (NullableType::class) && TypeIdentifier::ARRAY === $ collectionValueBaseType ->getTypeIdentifier ()
@@ -1221,4 +1270,129 @@ private function getMappedClass(array $data, string $class, array $context): str
12211270
12221271 return $ mappedClass ;
12231272 }
1273+
1274+ /**
1275+ * Cast each element of a scalar collection (int/float/bool/string) according to the element type.
1276+ *
1277+ * @param array<int|string,mixed> $data
1278+ *
1279+ * @return array<int|string,mixed>
1280+ */
1281+ private function castScalarCollectionElements (
1282+ array $ data ,
1283+ Type $ valueType ,
1284+ BuiltinType $ baseType ,
1285+ bool $ isDisabledTypeEnforcement ,
1286+ ): array {
1287+ $ nullable = false ; // Determine element nullability by unwrapping NullableType/WrappingTypeInterface layers
1288+ $ candidateType = $ valueType ;
1289+ while ($ candidateType instanceof WrappingTypeInterface) {
1290+ if ($ candidateType instanceof NullableType) {
1291+ $ nullable = true ;
1292+ break ;
1293+ }
1294+ $ candidateType = $ candidateType ->getWrappedType ();
1295+ }
1296+ $ collectionItemTypeIdentifier = $ baseType ->getTypeIdentifier (); // INT/FLOAT/BOOL/STRING
1297+ // Cast function preserves array keys to avoid breaking associative collections
1298+ $ cast = static function (mixed $ v ) use (
1299+ $ collectionItemTypeIdentifier ,
1300+ $ nullable ,
1301+ $ isDisabledTypeEnforcement
1302+ ) {
1303+ // Null handling
1304+ if (null === $ v ) {
1305+ if ($ nullable || $ isDisabledTypeEnforcement ) {
1306+ return null ;
1307+ }
1308+
1309+ throw NotNormalizableValueException::createForUnexpectedDataType ('Null is not allowed for a non-nullable collection element. ' , $ v , [$ collectionItemTypeIdentifier ->name ]);
1310+ }
1311+
1312+ switch ($ collectionItemTypeIdentifier ) {
1313+ case TypeIdentifier::INT :
1314+ if (\is_int ($ v )) {
1315+ return $ v ;
1316+ }
1317+ // Strict, safe integer-string (no spaces, no decimals, optional sign)
1318+ if (\is_string ($ v ) && preg_match ('/^[+-]?\d+$/ ' , $ v )) {
1319+ return (int ) $ v ;
1320+ }
1321+ // Non-strict: allow scalar coercion only (avoid arrays/objects)
1322+ if ($ isDisabledTypeEnforcement && \is_scalar ($ v )) {
1323+ return (int ) $ v ; // e.g. "12abc"→12, true→1, 3.7→3, "abc"→0
1324+ }
1325+ break ;
1326+
1327+ case TypeIdentifier::FLOAT :
1328+ if (\is_float ($ v )) {
1329+ return $ v ;
1330+ }
1331+ if (\is_int ($ v )) {
1332+ return (float ) $ v ;
1333+ }
1334+ // Strict: numeric strings (accepts "1", "2.5", "-0.75", "1e3")
1335+ if (\is_string ($ v ) && is_numeric ($ v )) {
1336+ return (float ) $ v ;
1337+ }
1338+ // Non-strict: allow scalar coercion only
1339+ if ($ isDisabledTypeEnforcement && \is_scalar ($ v )) {
1340+ return (float ) $ v ; // e.g. "abc"→0.0, true→1.0
1341+ }
1342+ break ;
1343+
1344+ case TypeIdentifier::BOOL :
1345+ // Accept real booleans in any mode
1346+ if (\is_bool ($ v )) {
1347+ return $ v ;
1348+ }
1349+
1350+ // Accept int 0/1 even in strict mode
1351+ if (0 === $ v || 1 === $ v ) {
1352+ return (bool ) $ v ;
1353+ }
1354+
1355+ // Non-strict mode: allow common string synonyms and scalar coercion
1356+ if ($ isDisabledTypeEnforcement ) {
1357+ if (\is_string ($ v )) {
1358+ $ v = strtolower (trim ($ v ));
1359+ if (\in_array ($ v , ['1 ' , 'true ' , 'on ' , 'yes ' ], true )) {
1360+ return true ;
1361+ }
1362+ if (\in_array ($ v , ['0 ' , 'false ' , 'off ' , 'no ' ], true )) {
1363+ return false ;
1364+ }
1365+ }
1366+ // Last-chance scalar coercion (e.g., "abc"->true, ""->false)
1367+ if (\is_scalar ($ v )) {
1368+ return (bool ) $ v ;
1369+ }
1370+
1371+ // Arrays/objects remain invalid even in non-strict mode
1372+ throw NotNormalizableValueException::createForUnexpectedDataType ('Cannot coerce non-scalar value to bool. ' , $ v , ['bool ' , 'int ' , 'string ' ]);
1373+ }
1374+ break ;
1375+
1376+ case TypeIdentifier::STRING :
1377+ if (\is_string ($ v )) {
1378+ return $ v ;
1379+ }
1380+ if (!$ isDisabledTypeEnforcement ) {
1381+ break ;
1382+ }
1383+ if (\is_scalar ($ v )) {
1384+ return (string ) $ v ;
1385+ }
1386+ break ;
1387+ }
1388+
1389+ throw NotNormalizableValueException::createForUnexpectedDataType ('Failed to denormalize scalar collection element. ' , $ v , [$ collectionItemTypeIdentifier ->name ]);
1390+ };
1391+
1392+ foreach ($ data as $ k => $ elm ) {
1393+ $ data [$ k ] = $ cast ($ elm ); // preserve keys
1394+ }
1395+
1396+ return $ data ;
1397+ }
12241398}
0 commit comments