Skip to content

Commit cea88b0

Browse files
committed
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
1 parent 8dc8c8d commit cea88b0

3 files changed

Lines changed: 366 additions & 0 deletions

File tree

core/upgrade-guide.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,103 @@
11
# Upgrade Guide
22

3+
## API Platform 4.3 to 4.4
4+
5+
### Backwards-Incompatible Changes
6+
7+
#### Denormalization Type Errors on Unconstrained BackedEnum Properties Revert to HTTP 400
8+
9+
Prior to 4.4, `BackedEnum`-typed properties received special treatment: any serializer type mismatch
10+
during denormalization was unconditionally promoted to HTTP 422. Starting with 4.4, that implicit
11+
promotion is replaced by a constraint-aware check.
12+
13+
**Who is affected**: code that relied on enum-typed properties producing 422 without any Symfony
14+
Validator constraint (or Laravel rule) on the property.
15+
16+
**What to do (Symfony)**: add an explicit constraint on the enum property:
17+
18+
```php
19+
use Symfony\Component\Validator\Constraints as Assert;
20+
21+
#[Assert\Type(Status::class)]
22+
public Status $status;
23+
```
24+
25+
Alternatively, enable Symfony Validator's
26+
[auto-mapping](https://symfony.com/doc/current/validation/auto_mapping.html) on the resource class.
27+
Auto-mapping generates an implicit `Type` constraint from the PHP type declaration, which is
28+
sufficient for the 422 promotion to apply.
29+
30+
**What to do (Laravel)**: add a rule for the property in `rules`:
31+
32+
```php
33+
#[ApiResource(
34+
rules: ['status' => 'required']
35+
)]
36+
```
37+
38+
Properties that already carry any constraint or rule are unaffected — they continue to produce 422.
39+
40+
For the full rule tables and additional details, see
41+
[Constraint-Aware 422 for Denormalization Errors](../symfony/validation.md#constraint-aware-422-for-denormalization-errors)
42+
(Symfony) and the equivalent section in the
43+
[Laravel validation guide](../laravel/validation.md#constraint-aware-422-for-denormalization-errors).
44+
45+
## API Platform 4.2 to 4.3
46+
47+
### Breaking Changes
48+
49+
#### Doctrine Filters Require Explicit `property`
50+
51+
Doctrine parameter-based filters (`ExactFilter`, `IriFilter`, `PartialSearchFilter`, `UuidFilter`)
52+
now throw `InvalidArgumentException` if the `property` attribute is missing. If you have filter
53+
parameters without an explicit `property`, you must either add one or use the `:property`
54+
placeholder in your parameter name.
55+
56+
```php
57+
// Before (would silently work without property):
58+
#[ApiFilter(ExactFilter::class)]
59+
60+
// After (property is required):
61+
#[ApiFilter(ExactFilter::class, property: 'name')]
62+
// Or use the :property placeholder in the parameter name
63+
```
64+
65+
#### Readonly Doctrine Entities Lose PUT & PATCH
66+
67+
Entities marked as readonly via Doctrine metadata (`$classMetadata->markReadOnly()`) no longer
68+
expose PUT and PATCH operations. Clients sending PUT/PATCH to these resources will receive a 404. If
69+
you need write operations on readonly entities, explicitly define them in your `ApiResource`
70+
attribute.
71+
72+
#### JSON-LD `@type` with `output` and `itemUriTemplate`
73+
74+
When using `output` with `itemUriTemplate` on a collection operation, the JSON-LD `@type` now uses
75+
the resource class name instead of the output DTO class name for semantic consistency with
76+
`itemUriTemplate` behavior. Update any client code that relies on the DTO class name in `@type`.
77+
78+
### Behavioral Changes
79+
80+
#### `isGranted` Evaluated Before Provider
81+
82+
Security expressions are now evaluated before the state provider runs. Expressions that do not
83+
reference the `object` variable will be checked at the `pre_read` stage, improving security by
84+
preventing unnecessary database queries on unauthorized requests. Expressions that reference
85+
`object` still wait for the provider to resolve the entity. Review any security expressions that
86+
relied on provider side-effects running before authorization.
87+
88+
#### Hydra Class `@id` Now Always Uses `#ShortName`
89+
90+
Hydra documentation classes now consistently use `#ShortName` as their `@id` instead of schema.org
91+
type URIs (e.g. `schema:Product`). Semantic types configured via `types` are now exposed through
92+
`rdfs:subClassOf`. Clients should expect class `@id` and property range changes in the Hydra
93+
documentation if resources had custom `types` configured.
94+
95+
#### LDP-Compliant Response Headers
96+
97+
API responses now include `Allow` and `Accept-Post` headers per the Linked Data Platform
98+
specification. These are informational headers that help clients discover API capabilities and
99+
should not break existing integrations.
100+
3101
## API Platform 3.4
4102

5103
Remove the `keep_legacy_inflector`, the `event_listeners_backward_compatibility_layer` and the

laravel/validation.md

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,133 @@ class Book extends Model
1919
{
2020
}
2121
```
22+
23+
## Constraint-Aware 422 for Denormalization Errors
24+
25+
Starting with API Platform 4.4, type mismatches detected during input denormalization (for example,
26+
the client sends `"foo"` for an `int` field, or `null` for a non-nullable property) are promoted to
27+
HTTP 422 validation responses when the affected property has a matching Laravel validation rule.
28+
When no matching rule exists, API Platform rethrows the original serializer exception as an honest
29+
HTTP 400.
30+
31+
This eliminates the need to write a custom middleware or exception handler solely to convert 400
32+
serializer errors into 422 validation responses.
33+
34+
### How It Works
35+
36+
`DeserializeProvider` catches denormalization exceptions from the Symfony Serializer. It delegates
37+
to `ApiPlatform\Laravel\State\DenormalizationViolationFactory`, which reads the `rules` declared on
38+
the operation and applies the following rule table:
39+
40+
| Serializer `currentType` | Matching rule in `rules` | Code |
41+
| --- | --- | --- |
42+
| `null` | `required`, `filled` | `blank` |
43+
| `null` | `present` | `null` |
44+
| any wrong type | `string`, `integer`, `int`, `numeric`, `boolean`, `bool`, `array`, `date`, `json` | `invalid_type` |
45+
| any wrong type | any other rule (when `nullable` is absent) | `invalid_type` |
46+
| `null` | `nullable` only (no `required`, `present`, or `filled`) | 400 (rethrow) |
47+
| any | (no rule for the property) | 400 (rethrow) |
48+
49+
Rules may be declared in string pipe-separated form (`'required|integer'`) or array form
50+
(`['required', 'integer']`). Object-based rules (`Rule`, `ValidationRule`) and
51+
`FormRequest`-class rule sets are skipped — `FormRequest` contracts run during the
52+
validation phase against the raw request, not the denormalized body.
53+
54+
### Example
55+
56+
```php
57+
// app/Models/Book.php
58+
59+
use ApiPlatform\Metadata\ApiResource;
60+
use Illuminate\Database\Eloquent\Model;
61+
62+
#[ApiResource(
63+
rules: [
64+
'title' => 'required|string',
65+
'year' => 'required|integer',
66+
]
67+
)]
68+
class Book extends Model
69+
{
70+
protected $fillable = ['title', 'year'];
71+
}
72+
```
73+
74+
Sending `null` for the `year` field:
75+
76+
```http
77+
POST /api/books HTTP/1.1
78+
Content-Type: application/json
79+
80+
{"title": "Dune", "year": null}
81+
```
82+
83+
Returns 422 with `blank` code because `required` is present:
84+
85+
```json
86+
{
87+
"type": "/validation_errors/abc123",
88+
"title": "Validation Error",
89+
"description": "year: This value should not be blank.",
90+
"status": 422,
91+
"violations": [
92+
{
93+
"propertyPath": "year",
94+
"message": "This value should not be blank.",
95+
"code": "blank"
96+
}
97+
]
98+
}
99+
```
100+
101+
Sending a string for the `year` field:
102+
103+
```http
104+
POST /api/books HTTP/1.1
105+
Content-Type: application/json
106+
107+
{"title": "Dune", "year": "nineteen-sixty-five"}
108+
```
109+
110+
Returns 422 with `invalid_type` code because `integer` is present:
111+
112+
```json
113+
{
114+
"type": "/validation_errors/def456",
115+
"title": "Validation Error",
116+
"description": "year: This value should be of type integer.",
117+
"status": 422,
118+
"violations": [
119+
{
120+
"propertyPath": "year",
121+
"message": "This value should be of type integer.",
122+
"code": "invalid_type"
123+
}
124+
]
125+
}
126+
```
127+
128+
If the `year` property had no rule at all, both requests would receive HTTP 400 instead.
129+
130+
### Nullable Fields
131+
132+
A field declared as `nullable` without `required`, `present`, or `filled` explicitly permits `null`
133+
values, so a `null` submission for such a field is not promoted to 422 and rethrows the original
134+
400:
135+
136+
```php
137+
#[ApiResource(
138+
rules: [
139+
'publishedAt' => 'nullable|date',
140+
]
141+
)]
142+
```
143+
144+
Sending `null` for `publishedAt` with only `nullable|date` produces HTTP 400, not 422.
145+
146+
### Relationship with Symfony Validation
147+
148+
The constraint-aware 422 behavior described above operates on the Laravel rules defined on the
149+
operation. It is independent from the Symfony Validator stack. For the equivalent Symfony
150+
integration, see the
151+
[Validation with Symfony documentation](../symfony/validation.md#constraint-aware-422-for-denormalization-errors).

symfony/validation.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,3 +648,141 @@ If the submitted data has denormalization errors, the HTTP status code will be s
648648

649649
You can also enable collecting of denormalization errors globally in the
650650
[Global Resources Defaults](https://api-platform.com/docs/core/configuration/#global-resources-defaults).
651+
652+
## Constraint-Aware 422 for Denormalization Errors
653+
654+
Starting with API Platform 4.4, type mismatches detected during input denormalization (for example,
655+
the client sends `"foo"` for an `int` field, or `null` for a non-nullable property) are promoted to
656+
HTTP 422 validation responses when the affected property has a matching Symfony Validator constraint.
657+
When no matching constraint exists, API Platform rethrows the original serializer exception as an
658+
honest HTTP 400.
659+
660+
This eliminates the need to enable `collectDenormalizationErrors` on every resource or write a
661+
custom event listener solely to convert 400 serializer errors into 422 validation responses.
662+
663+
### How It Works
664+
665+
`DeserializeProvider` catches `NotNormalizableValueException` and `PartialDenormalizationException`
666+
from the Symfony Serializer. It delegates to `ApiPlatform\Validator\DenormalizationViolationFactory`,
667+
which reads the Symfony Validator metadata for the operation's resource class and applies the
668+
following rule table:
669+
670+
| Serializer `currentType` | Matching constraint on the property | HTTP status | Violation code |
671+
|--------------------------|-------------------------------------|-------------|----------------------------|
672+
| `null` | `NotBlank` | 422 | `NotBlank::IS_BLANK_ERROR` |
673+
| `null` | `NotNull` | 422 | `NotNull::IS_NULL_ERROR` |
674+
| any wrong type | `Type` | 422 | `Type::INVALID_TYPE_ERROR` |
675+
| any wrong type | any other constraint | 422 | `Type::INVALID_TYPE_ERROR` |
676+
| any wrong type | (no constraint) | 400 | original exception rethrown|
677+
678+
In `collectDenormalizationErrors` mode (where the serializer raises `PartialDenormalizationException`
679+
instead of failing on the first error), properties without any constraint still emit a generic
680+
`Type::INVALID_TYPE_ERROR` violation so the 422 response surface remains consistent with prior
681+
behavior.
682+
683+
Validation groups set via `Operation::getValidationContext()['groups']` are respected when looking
684+
up constraints.
685+
686+
### Example
687+
688+
```php
689+
<?php
690+
// api/src/Entity/Book.php
691+
namespace App\Entity;
692+
693+
use ApiPlatform\Metadata\ApiResource;
694+
use Doctrine\ORM\Mapping as ORM;
695+
use Symfony\Component\Validator\Constraints as Assert;
696+
697+
#[ORM\Entity]
698+
#[ApiResource]
699+
class Book
700+
{
701+
#[ORM\Id, ORM\Column, ORM\GeneratedValue]
702+
private ?int $id = null;
703+
704+
#[ORM\Column]
705+
#[Assert\NotBlank]
706+
public string $title;
707+
708+
#[ORM\Column]
709+
#[Assert\NotNull]
710+
#[Assert\Type('int')]
711+
public int $year;
712+
}
713+
```
714+
715+
Sending `null` for the `year` field:
716+
717+
```http
718+
POST /books HTTP/1.1
719+
Content-Type: application/ld+json
720+
721+
{"title": "Dune", "year": null}
722+
```
723+
724+
Returns a 422 using the `NotNull` constraint message:
725+
726+
```json
727+
{
728+
"@context": "/contexts/ConstraintViolationList",
729+
"@type": "ConstraintViolationList",
730+
"title": "An error occurred",
731+
"description": "year: This value should not be null.",
732+
"violations": [
733+
{
734+
"propertyPath": "year",
735+
"message": "This value should not be null.",
736+
"code": "ad32d13f-c3d4-423b-909a-857b961eb720"
737+
}
738+
]
739+
}
740+
```
741+
742+
Sending a string for the `year` field:
743+
744+
```http
745+
POST /books HTTP/1.1
746+
Content-Type: application/ld+json
747+
748+
{"title": "Dune", "year": "nineteen-sixty-five"}
749+
```
750+
751+
Returns a 422 using the `Type` constraint message:
752+
753+
```json
754+
{
755+
"@context": "/contexts/ConstraintViolationList",
756+
"@type": "ConstraintViolationList",
757+
"title": "An error occurred",
758+
"description": "year: This value should be of type int.",
759+
"violations": [
760+
{
761+
"propertyPath": "year",
762+
"message": "This value should be of type int.",
763+
"code": "ba785a8c-82cb-4283-967c-3cf342181b40"
764+
}
765+
]
766+
}
767+
```
768+
769+
If `year` had no validator constraint at all, both requests would receive HTTP 400 instead.
770+
771+
### BackedEnum Properties
772+
773+
Prior to 4.4, a special case promoted `BackedEnum` denormalization failures to 422 unconditionally.
774+
That implicit promotion has been replaced by the constraint-aware rule above:
775+
776+
- Enum properties annotated with `#[Assert\NotNull]`, `#[Assert\Type]`, or any other constraint
777+
continue to produce 422 responses.
778+
- Enum properties with **no constraints** now receive HTTP 400.
779+
780+
To preserve the 422 behavior for an unconstrained enum property, either enable Symfony Validator's
781+
[auto-mapping](https://symfony.com/doc/current/validation/auto_mapping.html) on the resource class
782+
(which generates an implicit `Type` constraint from the PHP type declaration) or add an explicit
783+
constraint:
784+
785+
```php
786+
#[Assert\Type(Status::class)]
787+
public Status $status;
788+
```

0 commit comments

Comments
 (0)