Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions core/upgrade-guide.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
130 changes: 130 additions & 0 deletions laravel/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
This eliminates the need to write a custom middleware or exception handler solely to convert 400
serializer errors into 422 validation responses.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applied


### 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).
138 changes: 138 additions & 0 deletions symfony/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

applied


### 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
<?php
// api/src/Entity/Book.php
namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
#[ApiResource]
class Book
{
#[ORM\Id, ORM\Column, ORM\GeneratedValue]
private ?int $id = null;

#[ORM\Column]
#[Assert\NotBlank]
public string $title;

#[ORM\Column]
#[Assert\NotNull]
#[Assert\Type('int')]
public int $year;
}
```

Sending `null` for the `year` field:

```http
POST /books HTTP/1.1
Content-Type: application/ld+json

{"title": "Dune", "year": null}
```

Returns a 422 using the `NotNull` constraint message:

```json
{
"@context": "/contexts/ConstraintViolationList",
"@type": "ConstraintViolationList",
"title": "An error occurred",
"description": "year: This value should not be null.",
"violations": [
{
"propertyPath": "year",
"message": "This value should not be null.",
"code": "ad32d13f-c3d4-423b-909a-857b961eb720"
}
]
}
```

Sending a string for the `year` field:

```http
POST /books HTTP/1.1
Content-Type: application/ld+json

{"title": "Dune", "year": "nineteen-sixty-five"}
```

Returns a 422 using the `Type` constraint message:

```json
{
"@context": "/contexts/ConstraintViolationList",
"@type": "ConstraintViolationList",
"title": "An error occurred",
"description": "year: This value should be of type int.",
"violations": [
{
"propertyPath": "year",
"message": "This value should be of type int.",
"code": "ba785a8c-82cb-4283-967c-3cf342181b40"
}
]
}
```

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.

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:

```php
#[Assert\Type(Status::class)]
public Status $status;
```
Loading