@@ -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 /**
@@ -773,7 +779,6 @@ private function validateAndDenormalize(Type $type, string $currentClass, string
773779 return (int ) $ data ;
774780 }
775781 }
776-
777782 if ($ collectionValueType ) {
778783 try {
779784 $ collectionValueBaseType = $ collectionValueType ;
@@ -795,6 +800,19 @@ private function validateAndDenormalize(Type $type, string $currentClass, string
795800 $ class = $ collectionValueBaseType ->getClassName ().'[] ' ;
796801 $ context ['key_type ' ] = $ collectionKeyType ;
797802 $ context ['value_type ' ] = $ collectionValueType ;
803+ } elseif (
804+ !empty ($ context [self ::CAST_SCALAR_COLLECTIONS ])
805+ && $ type instanceof CollectionType
806+ && $ type ->isList ()
807+ && $ collectionValueBaseType instanceof BuiltinType
808+ && \is_array ($ data )
809+ ) {
810+ $ data = $ this ->castScalarCollectionElements (
811+ $ data ,
812+ $ collectionValueType , // original (possibly wrapped) value type
813+ $ collectionValueBaseType , // unwrapped BuiltinType: int/float/bool/string
814+ (bool ) ($ context [self ::DISABLE_TYPE_ENFORCEMENT ] ?? false ) // loose mode
815+ );
798816 } elseif (
799817 // BC layer for type-info < 7.2
800818 !class_exists (NullableType::class) && TypeIdentifier::ARRAY === $ collectionValueBaseType ->getTypeIdentifier ()
@@ -1221,4 +1239,129 @@ private function getMappedClass(array $data, string $class, array $context): str
12211239
12221240 return $ mappedClass ;
12231241 }
1242+
1243+ /**
1244+ * Cast each element of a scalar collection (int/float/bool/string) according to the element type.
1245+ *
1246+ * @param array<int|string,mixed> $data
1247+ *
1248+ * @return array<int|string,mixed>
1249+ */
1250+ private function castScalarCollectionElements (
1251+ array $ data ,
1252+ Type $ valueType ,
1253+ BuiltinType $ baseType ,
1254+ bool $ isDisabledTypeEnforcement ,
1255+ ): array {
1256+ $ nullable = false ; // Determine element nullability by unwrapping NullableType/WrappingTypeInterface layers
1257+ $ candidateType = $ valueType ;
1258+ while ($ candidateType instanceof WrappingTypeInterface) {
1259+ if ($ candidateType instanceof NullableType) {
1260+ $ nullable = true ;
1261+ break ;
1262+ }
1263+ $ candidateType = $ candidateType ->getWrappedType ();
1264+ }
1265+ $ collectionItemTypeIdentifier = $ baseType ->getTypeIdentifier (); // INT/FLOAT/BOOL/STRING
1266+ // Cast function preserves array keys to avoid breaking associative collections
1267+ $ cast = static function (mixed $ v ) use (
1268+ $ collectionItemTypeIdentifier ,
1269+ $ nullable ,
1270+ $ isDisabledTypeEnforcement
1271+ ) {
1272+ // Null handling
1273+ if (null === $ v ) {
1274+ if ($ nullable || $ isDisabledTypeEnforcement ) {
1275+ return null ;
1276+ }
1277+
1278+ throw NotNormalizableValueException::createForUnexpectedDataType ('Null is not allowed for a non-nullable collection element. ' , $ v , [$ collectionItemTypeIdentifier ->name ]);
1279+ }
1280+
1281+ switch ($ collectionItemTypeIdentifier ) {
1282+ case TypeIdentifier::INT :
1283+ if (\is_int ($ v )) {
1284+ return $ v ;
1285+ }
1286+ // Strict, safe integer-string (no spaces, no decimals, optional sign)
1287+ if (\is_string ($ v ) && preg_match ('/^[+-]?\d+$/ ' , $ v )) {
1288+ return (int ) $ v ;
1289+ }
1290+ // Non-strict: allow scalar coercion only (avoid arrays/objects)
1291+ if ($ isDisabledTypeEnforcement && \is_scalar ($ v )) {
1292+ return (int ) $ v ; // e.g. "12abc"→12, true→1, 3.7→3, "abc"→0
1293+ }
1294+ break ;
1295+
1296+ case TypeIdentifier::FLOAT :
1297+ if (\is_float ($ v )) {
1298+ return $ v ;
1299+ }
1300+ if (\is_int ($ v )) {
1301+ return (float ) $ v ;
1302+ }
1303+ // Strict: numeric strings (accepts "1", "2.5", "-0.75", "1e3")
1304+ if (\is_string ($ v ) && is_numeric ($ v )) {
1305+ return (float ) $ v ;
1306+ }
1307+ // Non-strict: allow scalar coercion only
1308+ if ($ isDisabledTypeEnforcement && \is_scalar ($ v )) {
1309+ return (float ) $ v ; // e.g. "abc"→0.0, true→1.0
1310+ }
1311+ break ;
1312+
1313+ case TypeIdentifier::BOOL :
1314+ // Accept real booleans in any mode
1315+ if (\is_bool ($ v )) {
1316+ return $ v ;
1317+ }
1318+
1319+ // Accept int 0/1 even in strict mode
1320+ if (0 === $ v || 1 === $ v ) {
1321+ return (bool ) $ v ;
1322+ }
1323+
1324+ // Non-strict mode: allow common string synonyms and scalar coercion
1325+ if ($ isDisabledTypeEnforcement ) {
1326+ if (\is_string ($ v )) {
1327+ $ v = strtolower (trim ($ v ));
1328+ if (\in_array ($ v , ['1 ' , 'true ' , 'on ' , 'yes ' ], true )) {
1329+ return true ;
1330+ }
1331+ if (\in_array ($ v , ['0 ' , 'false ' , 'off ' , 'no ' ], true )) {
1332+ return false ;
1333+ }
1334+ }
1335+ // Last-chance scalar coercion (e.g., "abc"->true, ""->false)
1336+ if (\is_scalar ($ v )) {
1337+ return (bool ) $ v ;
1338+ }
1339+
1340+ // Arrays/objects remain invalid even in non-strict mode
1341+ throw NotNormalizableValueException::createForUnexpectedDataType ('Cannot coerce non-scalar value to bool. ' , $ v , ['bool ' , 'int ' , 'string ' ]);
1342+ }
1343+ break ;
1344+
1345+ case TypeIdentifier::STRING :
1346+ if (\is_string ($ v )) {
1347+ return $ v ;
1348+ }
1349+ if (!$ isDisabledTypeEnforcement ) {
1350+ break ;
1351+ }
1352+ if (\is_scalar ($ v )) {
1353+ return (string ) $ v ;
1354+ }
1355+ break ;
1356+ }
1357+
1358+ throw NotNormalizableValueException::createForUnexpectedDataType ('Failed to denormalize scalar collection element. ' , $ v , [$ collectionItemTypeIdentifier ->name ]);
1359+ };
1360+
1361+ foreach ($ data as $ k => $ elm ) {
1362+ $ data [$ k ] = $ cast ($ elm ); // preserve keys
1363+ }
1364+
1365+ return $ data ;
1366+ }
12241367}
0 commit comments