From fe02f1c0cdaeb72829cfc33e9389618032aae306 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 2 Jun 2026 11:20:53 +0200 Subject: [PATCH 1/2] docs: document 422 denormalization error handling Explains the new constraint-aware 422 response for deserialization failures introduced in api-platform/core#7981. Covers Symfony and Laravel validation pages plus upgrade guide entry for 4.3. Refs api-platform/core#7981 --- core/upgrade-guide.md | 42 +++++++++++++ laravel/validation.md | 130 +++++++++++++++++++++++++++++++++++++++ symfony/validation.md | 138 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+) diff --git a/core/upgrade-guide.md b/core/upgrade-guide.md index e1e70b934d6..a0f29957d14 100644 --- a/core/upgrade-guide.md +++ b/core/upgrade-guide.md @@ -1,5 +1,47 @@ # Upgrade Guide +## API Platform 4.3 to 4.4 + +### Backwards-Incompatible Changes + +#### Denormalization Type Errors on Unconstrained BackedEnum Properties Revert to HTTP 400 + +Prior to 4.4, `BackedEnum`-typed properties received special treatment: any serializer type mismatch +during denormalization was unconditionally promoted to HTTP 422. Starting with 4.4, that implicit +promotion is replaced by a constraint-aware check. + +**Who is affected**: code that relied on enum-typed properties producing 422 without any Symfony +Validator constraint (or Laravel rule) on the property. + +**What to do (Symfony)**: add an explicit constraint on the enum property: + +```php +use Symfony\Component\Validator\Constraints as Assert; + +#[Assert\Type(Status::class)] +public Status $status; +``` + +Alternatively, enable Symfony Validator's +[auto-mapping](https://symfony.com/doc/current/validation/auto_mapping.html) on the resource class. +Auto-mapping generates an implicit `Type` constraint from the PHP type declaration, which is +sufficient for the 422 promotion to apply. + +**What to do (Laravel)**: add a rule for the property in `rules`: + +```php +#[ApiResource( + rules: ['status' => 'required'] +)] +``` + +Properties that already carry any constraint or rule are unaffected — they continue to produce 422. + +For the full rule tables and additional details, see +[Constraint-Aware 422 for Denormalization Errors](../symfony/validation.md#constraint-aware-422-for-denormalization-errors) +(Symfony) and the equivalent section in the +[Laravel validation guide](../laravel/validation.md#constraint-aware-422-for-denormalization-errors). + ## API Platform 4.2 to 4.3 ### Breaking Changes diff --git a/laravel/validation.md b/laravel/validation.md index 6c32d37db9a..f4beab1df57 100644 --- a/laravel/validation.md +++ b/laravel/validation.md @@ -19,3 +19,133 @@ class Book extends Model { } ``` + +## Constraint-Aware 422 for Denormalization Errors + +Starting with API Platform 4.4, type mismatches detected during input denormalization (for example, +the client sends `"foo"` for an `int` field, or `null` for a non-nullable property) are promoted to +HTTP 422 validation responses when the affected property has a matching Laravel validation rule. +When no matching rule exists, API Platform rethrows the original serializer exception as an honest +HTTP 400. + +This eliminates the need to write a custom middleware or exception handler solely to convert 400 +serializer errors into 422 validation responses. + +### How It Works + +`DeserializeProvider` catches denormalization exceptions from the Symfony Serializer. It delegates +to `ApiPlatform\Laravel\State\DenormalizationViolationFactory`, which reads the `rules` declared on +the operation and applies the following rule table: + +| Serializer `currentType` | Matching rule in `rules` | Code | +| --- | --- | --- | +| `null` | `required`, `filled` | `blank` | +| `null` | `present` | `null` | +| any wrong type | `string`, `integer`, `int`, `numeric`, `boolean`, `bool`, `array`, `date`, `json` | `invalid_type` | +| any wrong type | any other rule (when `nullable` is absent) | `invalid_type` | +| `null` | `nullable` only (no `required`, `present`, or `filled`) | 400 (rethrow) | +| any | (no rule for the property) | 400 (rethrow) | + +Rules may be declared in string pipe-separated form (`'required|integer'`) or array form +(`['required', 'integer']`). Object-based rules (`Rule`, `ValidationRule`) and +`FormRequest`-class rule sets are skipped — `FormRequest` contracts run during the +validation phase against the raw request, not the denormalized body. + +### Example + +```php +// app/Models/Book.php + +use ApiPlatform\Metadata\ApiResource; +use Illuminate\Database\Eloquent\Model; + +#[ApiResource( + rules: [ + 'title' => 'required|string', + 'year' => 'required|integer', + ] +)] +class Book extends Model +{ + protected $fillable = ['title', 'year']; +} +``` + +Sending `null` for the `year` field: + +```http +POST /api/books HTTP/1.1 +Content-Type: application/json + +{"title": "Dune", "year": null} +``` + +Returns 422 with `blank` code because `required` is present: + +```json +{ + "type": "/validation_errors/abc123", + "title": "Validation Error", + "description": "year: This value should not be blank.", + "status": 422, + "violations": [ + { + "propertyPath": "year", + "message": "This value should not be blank.", + "code": "blank" + } + ] +} +``` + +Sending a string for the `year` field: + +```http +POST /api/books HTTP/1.1 +Content-Type: application/json + +{"title": "Dune", "year": "nineteen-sixty-five"} +``` + +Returns 422 with `invalid_type` code because `integer` is present: + +```json +{ + "type": "/validation_errors/def456", + "title": "Validation Error", + "description": "year: This value should be of type integer.", + "status": 422, + "violations": [ + { + "propertyPath": "year", + "message": "This value should be of type integer.", + "code": "invalid_type" + } + ] +} +``` + +If the `year` property had no rule at all, both requests would receive HTTP 400 instead. + +### Nullable Fields + +A field declared as `nullable` without `required`, `present`, or `filled` explicitly permits `null` +values, so a `null` submission for such a field is not promoted to 422 and rethrows the original +400: + +```php +#[ApiResource( + rules: [ + 'publishedAt' => 'nullable|date', + ] +)] +``` + +Sending `null` for `publishedAt` with only `nullable|date` produces HTTP 400, not 422. + +### Relationship with Symfony Validation + +The constraint-aware 422 behavior described above operates on the Laravel rules defined on the +operation. It is independent from the Symfony Validator stack. For the equivalent Symfony +integration, see the +[Validation with Symfony documentation](../symfony/validation.md#constraint-aware-422-for-denormalization-errors). diff --git a/symfony/validation.md b/symfony/validation.md index d4e733b171e..8f26f31274c 100644 --- a/symfony/validation.md +++ b/symfony/validation.md @@ -648,3 +648,141 @@ If the submitted data has denormalization errors, the HTTP status code will be s You can also enable collecting of denormalization errors globally in the [Global Resources Defaults](https://api-platform.com/docs/core/configuration/#global-resources-defaults). + +## Constraint-Aware 422 for Denormalization Errors + +Starting with API Platform 4.4, type mismatches detected during input denormalization (for example, +the client sends `"foo"` for an `int` field, or `null` for a non-nullable property) are promoted to +HTTP 422 validation responses when the affected property has a matching Symfony Validator constraint. +When no matching constraint exists, API Platform rethrows the original serializer exception as an +honest HTTP 400. + +This eliminates the need to enable `collectDenormalizationErrors` on every resource or write a +custom event listener solely to convert 400 serializer errors into 422 validation responses. + +### How It Works + +`DeserializeProvider` catches `NotNormalizableValueException` and `PartialDenormalizationException` +from the Symfony Serializer. It delegates to `ApiPlatform\Validator\DenormalizationViolationFactory`, +which reads the Symfony Validator metadata for the operation's resource class and applies the +following rule table: + +| Serializer `currentType` | Matching constraint on the property | HTTP status | Violation code | +|--------------------------|-------------------------------------|-------------|----------------------------| +| `null` | `NotBlank` | 422 | `NotBlank::IS_BLANK_ERROR` | +| `null` | `NotNull` | 422 | `NotNull::IS_NULL_ERROR` | +| any wrong type | `Type` | 422 | `Type::INVALID_TYPE_ERROR` | +| any wrong type | any other constraint | 422 | `Type::INVALID_TYPE_ERROR` | +| any wrong type | (no constraint) | 400 | original exception rethrown| + +In `collectDenormalizationErrors` mode (where the serializer raises `PartialDenormalizationException` +instead of failing on the first error), properties without any constraint still emit a generic +`Type::INVALID_TYPE_ERROR` violation so the 422 response surface remains consistent with prior +behavior. + +Validation groups set via `Operation::getValidationContext()['groups']` are respected when looking +up constraints. + +### Example + +```php + Date: Tue, 2 Jun 2026 11:40:03 +0200 Subject: [PATCH 2/2] docs(validation): drop preamble blurbs, replace BC enum section Remove "eliminates the need to..." sentences per review feedback. Replace BackedEnum BC note with generic auto_mapping best-practice tip. Apply prettier --prose-wrap always. --- laravel/validation.md | 25 ++++++++-------- symfony/validation.md | 66 ++++++++++++++++++------------------------- 2 files changed, 39 insertions(+), 52 deletions(-) diff --git a/laravel/validation.md b/laravel/validation.md index f4beab1df57..e147f3323f4 100644 --- a/laravel/validation.md +++ b/laravel/validation.md @@ -28,28 +28,25 @@ HTTP 422 validation responses when the affected property has a matching Laravel When no matching rule exists, API Platform rethrows the original serializer exception as an honest HTTP 400. -This eliminates the need to write a custom middleware or exception handler solely to convert 400 -serializer errors into 422 validation responses. - ### How It Works `DeserializeProvider` catches denormalization exceptions from the Symfony Serializer. It delegates to `ApiPlatform\Laravel\State\DenormalizationViolationFactory`, which reads the `rules` declared on the operation and applies the following rule table: -| Serializer `currentType` | Matching rule in `rules` | Code | -| --- | --- | --- | -| `null` | `required`, `filled` | `blank` | -| `null` | `present` | `null` | -| any wrong type | `string`, `integer`, `int`, `numeric`, `boolean`, `bool`, `array`, `date`, `json` | `invalid_type` | -| any wrong type | any other rule (when `nullable` is absent) | `invalid_type` | -| `null` | `nullable` only (no `required`, `present`, or `filled`) | 400 (rethrow) | -| any | (no rule for the property) | 400 (rethrow) | +| Serializer `currentType` | Matching rule in `rules` | Code | +| ------------------------ | --------------------------------------------------------------------------------- | -------------- | +| `null` | `required`, `filled` | `blank` | +| `null` | `present` | `null` | +| any wrong type | `string`, `integer`, `int`, `numeric`, `boolean`, `bool`, `array`, `date`, `json` | `invalid_type` | +| any wrong type | any other rule (when `nullable` is absent) | `invalid_type` | +| `null` | `nullable` only (no `required`, `present`, or `filled`) | 400 (rethrow) | +| any | (no rule for the property) | 400 (rethrow) | Rules may be declared in string pipe-separated form (`'required|integer'`) or array form -(`['required', 'integer']`). Object-based rules (`Rule`, `ValidationRule`) and -`FormRequest`-class rule sets are skipped — `FormRequest` contracts run during the -validation phase against the raw request, not the denormalized body. +(`['required', 'integer']`). Object-based rules (`Rule`, `ValidationRule`) and `FormRequest`-class +rule sets are skipped — `FormRequest` contracts run during the validation phase against the raw +request, not the denormalized body. ### Example diff --git a/symfony/validation.md b/symfony/validation.md index 8f26f31274c..d5fdf1df111 100644 --- a/symfony/validation.md +++ b/symfony/validation.md @@ -653,32 +653,29 @@ You can also enable collecting of denormalization errors globally in the Starting with API Platform 4.4, type mismatches detected during input denormalization (for example, the client sends `"foo"` for an `int` field, or `null` for a non-nullable property) are promoted to -HTTP 422 validation responses when the affected property has a matching Symfony Validator constraint. -When no matching constraint exists, API Platform rethrows the original serializer exception as an -honest HTTP 400. - -This eliminates the need to enable `collectDenormalizationErrors` on every resource or write a -custom event listener solely to convert 400 serializer errors into 422 validation responses. +HTTP 422 validation responses when the affected property has a matching Symfony Validator +constraint. When no matching constraint exists, API Platform rethrows the original serializer +exception as an honest HTTP 400. ### How It Works `DeserializeProvider` catches `NotNormalizableValueException` and `PartialDenormalizationException` -from the Symfony Serializer. It delegates to `ApiPlatform\Validator\DenormalizationViolationFactory`, -which reads the Symfony Validator metadata for the operation's resource class and applies the -following rule table: - -| Serializer `currentType` | Matching constraint on the property | HTTP status | Violation code | -|--------------------------|-------------------------------------|-------------|----------------------------| -| `null` | `NotBlank` | 422 | `NotBlank::IS_BLANK_ERROR` | -| `null` | `NotNull` | 422 | `NotNull::IS_NULL_ERROR` | -| any wrong type | `Type` | 422 | `Type::INVALID_TYPE_ERROR` | -| any wrong type | any other constraint | 422 | `Type::INVALID_TYPE_ERROR` | -| any wrong type | (no constraint) | 400 | original exception rethrown| - -In `collectDenormalizationErrors` mode (where the serializer raises `PartialDenormalizationException` -instead of failing on the first error), properties without any constraint still emit a generic -`Type::INVALID_TYPE_ERROR` violation so the 422 response surface remains consistent with prior -behavior. +from the Symfony Serializer. It delegates to +`ApiPlatform\Validator\DenormalizationViolationFactory`, which reads the Symfony Validator metadata +for the operation's resource class and applies the following rule table: + +| Serializer `currentType` | Matching constraint on the property | HTTP status | Violation code | +| ------------------------ | ----------------------------------- | ----------- | --------------------------- | +| `null` | `NotBlank` | 422 | `NotBlank::IS_BLANK_ERROR` | +| `null` | `NotNull` | 422 | `NotNull::IS_NULL_ERROR` | +| any wrong type | `Type` | 422 | `Type::INVALID_TYPE_ERROR` | +| any wrong type | any other constraint | 422 | `Type::INVALID_TYPE_ERROR` | +| any wrong type | (no constraint) | 400 | original exception rethrown | + +In `collectDenormalizationErrors` mode (where the serializer raises +`PartialDenormalizationException` instead of failing on the first error), properties without any +constraint still emit a generic `Type::INVALID_TYPE_ERROR` violation so the 422 response surface +remains consistent with prior behavior. Validation groups set via `Operation::getValidationContext()['groups']` are respected when looking up constraints. @@ -768,21 +765,14 @@ Returns a 422 using the `Type` constraint message: If `year` had no validator constraint at all, both requests would receive HTTP 400 instead. -### BackedEnum Properties - -Prior to 4.4, a special case promoted `BackedEnum` denormalization failures to 422 unconditionally. -That implicit promotion has been replaced by the constraint-aware rule above: - -- Enum properties annotated with `#[Assert\NotNull]`, `#[Assert\Type]`, or any other constraint - continue to produce 422 responses. -- Enum properties with **no constraints** now receive HTTP 400. +### Tip: use Symfony Validator auto-mapping -To preserve the 422 behavior for an unconstrained enum property, either enable Symfony Validator's -[auto-mapping](https://symfony.com/doc/current/validation/auto_mapping.html) on the resource class -(which generates an implicit `Type` constraint from the PHP type declaration) or add an explicit -constraint: +When +[`framework.validation.auto_mapping`](https://symfony.com/doc/current/validation/auto_mapping.html) +is enabled, Symfony Validator derives implicit constraints directly from PHP type declarations — +`string`, `int`, `float`, nullability, `BackedEnum` cases, and more. Every property with a PHP type +then effectively has a `Type` constraint without any manual annotation. -```php -#[Assert\Type(Status::class)] -public Status $status; -``` +This means the constraint-aware 422 promotion applies across the board: you get consistent 422 +responses for type mismatches on all typed properties without scattering `#[Assert\Type]` +everywhere.