Skip to content

Nullable object properties (DateTimeInterface, Uuid, Ulid, BcMath\Number, enums) emit unactionable ClassName|null denormalization errors that contradict the JSON Schema #8392

Description

@bendavies

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,
    ) {
    }
}
  1. collectDenormalizationErrors: true.
  2. POST /things with {"date_time": false, "interval": false, "uuid": false, "ulid": false, "number": false} (any wrong-typed value reproduces it).
  3. Actual: 422, each violation message is This value should be of type <PhpClass>|null. with no hint.
  4. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions