API Platform version(s) affected: 4.3.16
Description
When a value of the wrong type is sent for a nullable object-typed property (?SomeClass), the 422 denormalization error reports the property's PHP type (for example Uuid|null) as the expected type. From an API consumer's point of view this message is useless: the type it names does not exist in the JSON Schema, cannot be sent over JSON, and the previously helpful hint is gone.
This is not limited to a single class. It affects every object type API Platform itself natively supports and maps to a JSON string in ApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory::getClassSchemaDefinition():
\DateTimeInterface (schema {"type": "string", "format": "date-time"})
\DateInterval (schema {"type": "string", "format": "duration"})
Symfony\Component\Uid\Uuid / Ramsey\Uuid\UuidInterface (schema {"type": "string", "format": "uuid"})
Symfony\Component\Uid\Ulid (schema {"type": "string", "format": "ulid"})
\BcMath\Number (schema {"type": "string", "format": "string"})
- backed enums (schema
{"type": "string", "enum": [...]})
The trigger is nullability alone. A non-nullable property of the same type produces a correct, actionable message; adding ? (making the resolved type a NullableType) flips it to the PHP class name.
Given a resource with nullable properties of these types, sending a value of the wrong type (here false) returns:
{ "propertyPath": "date_time", "message": "This value should be of type DateTimeImmutable|null." }
{ "propertyPath": "interval", "message": "This value should be of type DateInterval|null." }
{ "propertyPath": "uuid", "message": "This value should be of type Uuid|null." }
{ "propertyPath": "ulid", "message": "This value should be of type Ulid|null." }
{ "propertyPath": "number", "message": "This value should be of type Number|null." }
The non-nullable versions of the very same properties, given the same input, return the correct scalar type and keep the hint:
{ "propertyPath": "uuid", "message": "This value should be of type string.", "hint": "The data is not a valid \"Symfony\\Component\\Uid\\Uuid\" string representation." }
{ "propertyPath": "number", "message": "This value should be of type string|int.", "hint": "The data must be a \"string\" representing a decimal number, or an \"int\"." }
So making a property nullable is the entire difference between an actionable error and an unactionable one.
Uuid|null (and the others) are internal PHP types. They:
- do not appear anywhere in the OpenAPI/JSON Schema the consumer works from (the schema says
string with a format),
- are not something a consumer can send (you send a UUID/date/decimal string over JSON, not a PHP
Uuid/DateTimeImmutable/Number),
- drop the hint that previously told the consumer what a valid value looks like.
The error therefore contradicts the schema and is unactionable. On 4.1.x the same request against a nullable property produced the scalar type (string, string|int) and the hint, so this is a regression.
How to reproduce
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;
#[ApiResource(operations: [new Post(collectDenormalizationErrors: true)])]
final class Thing
{
public function __construct(
public readonly ?\DateTimeImmutable $dateTime = null,
public readonly ?\DateInterval $interval = null,
public readonly ?Uuid $uuid = null,
public readonly ?Ulid $ulid = null,
public readonly ?\BcMath\Number $number = null,
) {
}
}
collectDenormalizationErrors: true.
POST /things with {"date_time": false, "interval": false, "uuid": false, "ulid": false, "number": false} (any wrong-typed value reproduces it).
- Actual:
422, each violation message is This value should be of type <PhpClass>|null. with no hint.
- Expected: a message naming what the consumer can actually send (the scalar
string and the parsing hint), consistent with the JSON Schema, exactly as the non-nullable variants and 4.1.x produce.
Possible Solution
The cause is the interaction between ApiPlatform\Serializer\AbstractItemNormalizer::validateAttributeType() and the post-loop error handling in the same class.
validateAttributeType() reports (string) $type:
protected function validateAttributeType(string $attribute, Type $type, mixed $value, ?string $format = null, array $context = []): void
{
if ($type->isIdentifiedBy(TypeIdentifier::FLOAT) && null !== $format && str_contains($format, 'json')) {
$isValid = \is_float($value) || \is_int($value);
} else {
$isValid = $type->accepts($value);
}
if (!$isValid) {
throw NotNormalizableValueException::createForUnexpectedDataType(
\sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $type, \gettype($value)),
$value,
[(string) $type], // "Uuid|null" for a ?Uuid property
$context['deserialization_path'] ?? null,
);
}
}
For a non-nullable object property, the resolved type is an ObjectType, so the object member is denormalized by its dedicated Symfony normalizer (UidNormalizer, DateTimeNormalizer, DateIntervalNormalizer, the BcMath number normalizer, ...), which throws a NotNormalizableValueException with expectedTypes = ['string'] and a useful hint. That message is preserved.
For a nullable object property, the resolved type is a NullableType (a union). The denormalization loop treats it as "multiple types", stashes the good exception, and the post-loop block rebuilds the message from (string) $type instead of using the stashed normalizer message:
if ($denormalizationException) {
if ($type instanceof Type && $type->isSatisfiedBy(static fn ($type) => $type instanceof BuiltinType) && !$type->isSatisfiedBy($typeIsResourceClass)) {
$message = $type->isSatisfiedBy(static fn ($type) => $type instanceof ObjectType)
? $denormalizationException->getMessage()
: \sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $type, \gettype($value)); // "Uuid|null"
throw NotNormalizableValueException::createForUnexpectedDataType($message, $value, array_map(strval(...), $types), $context['deserialization_path'] ?? null, false, 0, $denormalizationException);
}
throw $denormalizationException;
}
For a nullable object type, either delegate the message to the dedicated object normalizer (use the stashed $denormalizationException->getMessage() and its expectedTypes), or build expectedTypes/the message from the JSON Schema (string + format) that SchemaPropertyMetadataFactory already computes for the same class, so the error matches the schema and is actionable for an API consumer.
Additional Context
Regression introduced by the TypeInfo migration in #6979 (which replaced PropertyInfo Type with TypeInfo Type and updated AbstractItemNormalizer); the legacy path was later removed in #8364. On 4.1.x the same request against a nullable object property produced a message naming the scalar (string/string|int) and the hint.
Related: #8388 reports the same underlying behavior for the specific case of backed enums (?SomeBackedEnum yields SomeBackedEnum|null). This issue is the general form: the message degrades for any nullable object-typed property that API Platform maps to a JSON string, including the framework's own supported value objects (DateTimeInterface, DateInterval, Uuid, Ulid, BcMath\Number).
API Platform version(s) affected: 4.3.16
Description
When a value of the wrong type is sent for a nullable object-typed property (
?SomeClass), the422denormalization error reports the property's PHP type (for exampleUuid|null) as the expected type. From an API consumer's point of view this message is useless: the type it names does not exist in the JSON Schema, cannot be sent over JSON, and the previously helpful hint is gone.This is not limited to a single class. It affects every object type API Platform itself natively supports and maps to a JSON
stringinApiPlatform\JsonSchema\Metadata\Property\Factory\SchemaPropertyMetadataFactory::getClassSchemaDefinition():\DateTimeInterface(schema{"type": "string", "format": "date-time"})\DateInterval(schema{"type": "string", "format": "duration"})Symfony\Component\Uid\Uuid/Ramsey\Uuid\UuidInterface(schema{"type": "string", "format": "uuid"})Symfony\Component\Uid\Ulid(schema{"type": "string", "format": "ulid"})\BcMath\Number(schema{"type": "string", "format": "string"}){"type": "string", "enum": [...]})The trigger is nullability alone. A non-nullable property of the same type produces a correct, actionable message; adding
?(making the resolved type aNullableType) flips it to the PHP class name.Given a resource with nullable properties of these types, sending a value of the wrong type (here
false) returns:{ "propertyPath": "date_time", "message": "This value should be of type DateTimeImmutable|null." } { "propertyPath": "interval", "message": "This value should be of type DateInterval|null." } { "propertyPath": "uuid", "message": "This value should be of type Uuid|null." } { "propertyPath": "ulid", "message": "This value should be of type Ulid|null." } { "propertyPath": "number", "message": "This value should be of type Number|null." }The non-nullable versions of the very same properties, given the same input, return the correct scalar type and keep the hint:
{ "propertyPath": "uuid", "message": "This value should be of type string.", "hint": "The data is not a valid \"Symfony\\Component\\Uid\\Uuid\" string representation." } { "propertyPath": "number", "message": "This value should be of type string|int.", "hint": "The data must be a \"string\" representing a decimal number, or an \"int\"." }So making a property nullable is the entire difference between an actionable error and an unactionable one.
Uuid|null(and the others) are internal PHP types. They:stringwith aformat),Uuid/DateTimeImmutable/Number),The error therefore contradicts the schema and is unactionable. On 4.1.x the same request against a nullable property produced the scalar type (
string,string|int) and the hint, so this is a regression.How to reproduce
collectDenormalizationErrors: true.POST /thingswith{"date_time": false, "interval": false, "uuid": false, "ulid": false, "number": false}(any wrong-typed value reproduces it).422, each violation message isThis value should be of type <PhpClass>|null.with no hint.stringand the parsing hint), consistent with the JSON Schema, exactly as the non-nullable variants and 4.1.x produce.Possible Solution
The cause is the interaction between
ApiPlatform\Serializer\AbstractItemNormalizer::validateAttributeType()and the post-loop error handling in the same class.validateAttributeType()reports(string) $type:For a non-nullable object property, the resolved type is an
ObjectType, so the object member is denormalized by its dedicated Symfony normalizer (UidNormalizer,DateTimeNormalizer,DateIntervalNormalizer, the BcMath number normalizer, ...), which throws aNotNormalizableValueExceptionwithexpectedTypes = ['string']and a useful hint. That message is preserved.For a nullable object property, the resolved type is a
NullableType(a union). The denormalization loop treats it as "multiple types", stashes the good exception, and the post-loop block rebuilds the message from(string) $typeinstead of using the stashed normalizer message:For a nullable object type, either delegate the message to the dedicated object normalizer (use the stashed
$denormalizationException->getMessage()and itsexpectedTypes), or buildexpectedTypes/the message from the JSON Schema (string+ format) thatSchemaPropertyMetadataFactoryalready computes for the same class, so the error matches the schema and is actionable for an API consumer.Additional Context
Regression introduced by the TypeInfo migration in #6979 (which replaced PropertyInfo
Typewith TypeInfoTypeand updatedAbstractItemNormalizer); the legacy path was later removed in #8364. On 4.1.x the same request against a nullable object property produced a message naming the scalar (string/string|int) and the hint.Related: #8388 reports the same underlying behavior for the specific case of backed enums (
?SomeBackedEnumyieldsSomeBackedEnum|null). This issue is the general form: the message degrades for any nullable object-typed property that API Platform maps to a JSONstring, including the framework's own supported value objects (DateTimeInterface,DateInterval,Uuid,Ulid,BcMath\Number).