Skip to content

fix(serializer): preserve denormalization errors for nullable object properties#8393

Open
bendavies wants to merge 1 commit into
api-platform:4.3from
bendavies:fix-nullable-object-denormalization-error
Open

fix(serializer): preserve denormalization errors for nullable object properties#8393
bendavies wants to merge 1 commit into
api-platform:4.3from
bendavies:fix-nullable-object-denormalization-error

Conversation

@bendavies

@bendavies bendavies commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #8392.

Built on top of #8389, whose commit is included in this branch; only the last commit is new here. I will rebase once #8389 merges. Related to #8388 (the backed enum flavor of the same regression, fixed by #8389); this PR covers the remaining nullable object types.

Description

When a value of the wrong type is sent for a nullable object typed property (?Uuid, ?Ulid, ?DateTimeImmutable, ?DateInterval, ?UuidInterface, ?\BcMath\Number, ...), the 422 violation collected with collectDenormalizationErrors: true reports the property's PHP type union as the expected type, with no hint:

{ "propertyPath": "ulid", "message": "This value should be of type Ulid|null." }

Ulid|null is the internal PHP type: it appears nowhere in the JSON Schema / OpenAPI output (which documents string plus a format), it cannot be sent over JSON, and it does not tell the consumer what to send.

The trigger is nullability alone. The non-nullable version of the same property yields the correct error from the dedicated normalizer (This value should be of type string. plus a hint), and 4.1.x produced the correct message for nullable properties too; this is a regression from the TypeInfo migration (#6979).

Root cause

In AbstractItemNormalizer::createAndValidateAttributeValue(), a nullable property type is a composite (NullableType), so the NotNormalizableValueException thrown by the dedicated normalizer (UidNormalizer, DateTimeNormalizer, DateIntervalNormalizer, API Platform's Ramsey UuidDenormalizer, ...) is stashed while the remaining union members are tried. Once they all fail, the error is rebuilt with expectedTypes = array_map(strval(...), $types), the stringified PHP union members. DeserializeProvider::createViolationFromException() renders the violation message from getExpectedTypes() and only surfaces the exception message as the hint, so the normalizer's accurate expected types (e.g. ["string"]) are lost, and the rebuild also passes false for useMessageForUser, so the hint disappears as well. Fixing only the message string would change nothing visible.

For a non-nullable property the union has a single member and the same exception is re-thrown unchanged, which is why nullability alone flips the result.

Change

When the union contains an object member that is neither a resource class nor an enum, re-throw the stashed normalizer exception with its expected types, message, hint and canUseMessageForUser() intact, extending the expected types with null when the property also accepts null:

property before after
?Ulid / ?DateTimeImmutable This value should be of type Ulid|null. (no hint) This value should be of type string|null. plus the normalizer hint
?UuidInterface (ramsey/uuid) This value should be of type UuidInterface|null. (no hint) This value should be of type uuid|null. plus hint Invalid UUID string: y

The label comes from whatever the dedicated normalizer reports as its accepted input types, matching the 4.1.x output extended with null. (For Ramsey UUIDs that label is uuid, a preexisting choice of UuidDenormalizer also visible on non-nullable properties; aligning it with the schema type string would be a separate change.)

Enum members are deliberately excluded from the re-throw: their violations keep flowing through the rebuilt exception that #8389 renders via the enum backing type, and #8389's EnumDenormalizationValidationTest passes on top of this change. Pure scalar unions (?string, ?int, ...) and resource relations are untouched.

Tests

  • Unit: nullable and non-nullable ?DateTimeImmutable parity tests asserting the surfaced exception keeps the normalizer's expected types (plus null for the nullable case), message, hint flag and path.
  • Functional: ValidationTest now asserts This value should be of type uuid|null. plus the Invalid UUID string: y hint on the native TypeInfo path; the legacy property-info path keeps This value should be of type uuid..

…properties

A value of the wrong type for a nullable object property (?Uuid,
?DateTimeImmutable, ...) produced "This value should be of type
Uuid|null." with no hint, while the non-nullable version of the same
property produced the schema accurate "This value should be of type
string." plus a hint. Nullability alone flipped the result: a nullable
type is a composite, so the dedicated normalizer's exception was
stashed, and once every union member failed the error was rebuilt from
the PHP type union. The rebuild kept the message, but the visible
violation is built by DeserializeProvider from getExpectedTypes(), and
those were replaced with the stringified union members.

Re-throw the stashed exception when the union contains an object
member that is neither a resource class nor an enum, extending its
expected types with "null" when the property accepts null. The label
comes from whatever the dedicated normalizer reports as its accepted
input types: a wrong value for a nullable Ulid property now renders
"This value should be of type string|null." plus the UidNormalizer
hint, matching the 4.1.x output extended with "null". Enums keep
flowing through the rebuilt exception and are mapped to their backing
type downstream; scalar unions and resource relations are untouched.
@soyuka soyuka force-pushed the fix-nullable-object-denormalization-error branch from 31d2079 to 522679a Compare July 11, 2026 07:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant