[Laravel] Optional BelongsTo relation cannot be set to null (500 on POST/PATCH)
メンテナーはふだん 1 日以内に返信
まだ誰も着手していません。
評価
- 難易度
- 4/5
- 見積もり時間
- 3〜5日
- 初心者へのやさしさ
- 54/100
調査の方向性
Start with EloquentPropertyMetadataFactory.php and PersistProcessor.php in api-platform/laravel; trace how nullable BelongsTo and MorphTo values are handled during denormalization and persistence. Check the existing metadata and persistence tests for relation coverage, then reproduce the POST and PATCH cases with an explicit null. Done means optional to-one relations accept null and clear the foreign key without causing a 500.
索引モデルが issue の本文から書いたものです。
説明
API Platform version(s) affected: 5.0.2 (api-platform/laravel, api-platform/serializer, api-platform/state, api-platform/metadata all 5.0.2)
Laravel 13.35.0, PHP 8.4.25
Description
An optional BelongsTo relation (foreign key column is nullable) rejects an explicit null on both POST and PATCH with a 500:
Symfony\Component\Serializer\Exception\NotNormalizableValueException:
The type of the "category" attribute must be "array" (nested document) or "string" (IRI), "NULL" given.
So a client can create the resource by omitting the key, but it can never clear the relation once it is set.
Two things combine to cause it:
ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactorywraps the native type of a plain column inType::nullable()when the column is nullable (if ($p['nullable'])), but for a relation it always doesType::object($relation['related'])and never looks at the foreign key column. The property is therefore non-nullable, andAbstractItemNormalizer::createAndValidateAttributeValue()does not short-circuit onnull(null === $value && $type->isNullable()), so it falls through todenormalizeRelation()which throws.- Even if the type is made nullable by hand (
nativeType: Type::nullable(Type::object(...))on#[ApiProperty]),ApiPlatform\Laravel\Eloquent\State\PersistProcessor::process()doesif (!isset($data->{$relation['name']})) { continue; }. Anullrelation is skipped,nullstays as a raw Eloquent attribute named after the relation, and the insert/update fails withSQLSTATE[42703]: column "category" of relation "shipping_methods" does not exist.
This works with API Platform on Symfony + Doctrine: Symfony\Bridge\Doctrine\PropertyInfo\DoctrineExtractor returns Type::nullable(Type::object($class)) for a single-valued association unless it is an identifier or a join column is explicitly nullable: false (isAssociationNullable()), so the same AbstractItemNormalizer accepts null and the association is simply set to null. The Eloquent metadata factory has no equivalent for BelongsTo.
Sending an empty string is not an alternative: it is treated as an IRI (Invalid IRI "" / No resource associated to "", 500).
Note: the failure is reported as a 500, while a NotNormalizableValueException for a bad payload is a client error elsewhere.
How to reproduce
Migration (relation column is nullable):
Schema::create('categories', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('name');
});
Schema::create('items', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('code');
$table->foreignUuid('category_id')->nullable()->constrained('categories')->nullOnDelete();
$table->timestamps();
});
Models:
use ApiPlatform\Metadata\ApiResource;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[ApiResource]
class Category extends Model
{
use HasUuids;
protected $fillable = ['name'];
}
#[ApiResource]
class Item extends Model
{
use HasUuids;
protected $fillable = ['code'];
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}
Requests:
POST /api/items {"code": "a"} -> 201 (relation omitted)
POST /api/items {"code": "a", "category": null} -> 500
PATCH /api/items/{id} {"category": "/api/categories/{uuid}"} -> 200
PATCH /api/items/{id} {"category": null} -> 500
PATCH /api/items/{id} {"category": ""} -> 500 (Invalid IRI "")
Possible Solution
Two small changes in api-platform/laravel (sketch against 5.0.2, not run against the vendor code itself; the workaround at the end is what we verified).
-
Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php: make an optionalBelongsTonullable, as already done for plain columns.Note that
ModelMetadata::getAttributes()skips theBelongsToforeign key columns, so the nullability has to be read from the schema for$relation['foreign_key'].
+use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ public function create(string $resourceClass, string $property, array $options = []): ApiProperty
$type = Type::object($relation['related']);
if ($collection) {
$type = Type::iterable($type);
+ } elseif (is_a($relation['type'], BelongsTo::class, true) && $this->isForeignKeyNullable($model, $relation['foreign_key'])) {
+ // same rule as Symfony's DoctrineExtractor::isAssociationNullable()
+ $type = Type::nullable($type);
}
@@
+ private function isForeignKeyNullable(Model $model, ?string $foreignKey): bool
+ {
+ if (null === $foreignKey) {
+ return false;
+ }
+
+ foreach ($model->getConnection()->getSchemaBuilder()->getColumns($model->getTable()) as $column) {
+ if ($column['name'] === $foreignKey) {
+ return (bool) $column['nullable'];
+ }
+ }
+
+ return false;
+ }
(In a real patch the columns should come from the schema already cached in ModelMetadata instead of querying them again.)
Eloquent/State/PersistProcessor.php: an explicitnullon a to-one relation must clear it instead of being skipped and written as a raw column.
foreach ($this->modelMetadata->getRelations($data) as $relation) {
+ if (
+ (BelongsTo::class === $relation['type'] || MorphTo::class === $relation['type'])
+ && \array_key_exists($relation['name'], $data->getAttributes())
+ && null === $data->getAttributes()[$relation['name']]
+ ) {
+ // explicit null: clear the foreign key and drop the pseudo attribute so it is not saved as a column
+ $data->{$relation['method_name']}()->dissociate();
+ unset($data->{$relation['name']});
+
+ continue;
+ }
+
if (!isset($data->{$relation['name']})) {
continue;
}
Together, {"category": null} on POST/PATCH sets the foreign key to null, like it does with Doctrine on Symfony.
Workaround we verified in our app (API Platform 5.0.2, all 56 tests of the resource pass): on the Eloquent model, declare the type and add a set-mutator.
use Symfony\Component\TypeInfo\Type;
#[ApiProperty(
serialize: new Groups(['item:read', 'item:write']),
property: 'category',
nativeType: new Type\NullableType(new Type\ObjectType(Category::class)),
)]
class Item extends Model
{
// ...
// API Platform assigns the relation as an attribute and only associates it when it is set,
// so null would otherwise be written as a raw "category" column.
public function setCategoryAttribute(mixed $value): void
{
if ($value === null) {
$this->category()->dissociate();
return;
}
$this->attributes['category'] = $value;
}
}
If the relation is validated with a Laravel FormRequest rule, it also needs nullable (e.g. ['sometimes', 'nullable', new MyIriRule()]), otherwise the rule receives null.
Additional Context
- The same applies to any optional
BelongsTo/MorphToon an Eloquent resource, not only to this example. - Stack trace (create with
"category": null):
Symfony\Component\Serializer\Exception\NotNormalizableValueException: The type of the "category" attribute must be "array" (nested document) or "string" (IRI), "NULL" given.
#0 vendor/api-platform/serializer/AbstractItemNormalizer.php(700): ...::createForUnexpectedDataType(...)
#1 vendor/api-platform/serializer/AbstractItemNormalizer.php(1083): ...->denormalizeRelation('category', ...)
#2 vendor/api-platform/serializer/AbstractItemNormalizer.php(1006): ...->createAndValidateAttributeValue('category', NULL, 'json', ...)
- 主要言語
- PHP
- スター
- 2.6k
- フォーク
- 989
- 平均マージ
- 1日 19時間
- マージ済み PR(30日)
- 87
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
api-platform/core のほかの issue
-
難易度 1/5 1時間未満 初心者へのやさしさ 85/100
api-platform/core#8680 ·
メンテナーはふだん 1 日以内に返信
-
[Metadata] Incorrect api schema for path variable対応中かも @bram123 が 1 日前に担当しました。 オープン
難易度 3/5 1〜2日 初心者へのやさしさ 68/100
api-platform/core#8646 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
DenormalizationViolationFactory crash when GroupSequence is used対応中かも このイシューにリンクされたプルリクエストがオープン中、またはマージ済みです。 オープン
難易度 3/5 1〜2日 初心者へのやさしさ 64/100
api-platform/core#8639 ·
メンテナーはふだん 1 日以内に返信
-
HTTP cache purgers error handling and performance issues対応中かも このイシューにリンクされたプルリクエストがオープン中、またはマージ済みです。 オープン
難易度 4/5 3〜5日 初心者へのやさしさ 38/100
api-platform/core#8591 ·
メンテナーはふだん 1 日以内に返信
-
難易度 4/5 3〜5日 初心者へのやさしさ 48/100
api-platform/core#8535 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
api-platform/core の issue をすべて見る
似ている issue
-
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
-
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
matomo-org/device-detector#8405 ·
メンテナーはふだん 1 日以内に返信
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 66/100
メンテナーはふだん 1 日以内に返信
-
sync-en
難易度 2/5 1〜3時間 初心者へのやさしさ 62/100
メンテナーはふだん 1 日以内に返信
-
sync-en
難易度 2/5 1〜3時間 初心者へのやさしさ 66/100
メンテナーはふだん 4 日以内に返信