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