Hacktoberfest 2026:维护者为十月标记出来的 issue,仍然开放、适合新手。 浏览 Hacktoberfest issue

[Laravel] Optional BelongsTo relation cannot be set to null (500 on POST/PATCH)

未关闭
#8,664 1 条评论 0 个 reaction 已指派 0 人 在 GitHub 查看

维护者通常 1 天内回复

还没有人认领这个 Issue。

评估

难度
4/5
预计耗时
3-5 天
新手友好度
54/100
Issue 类型
缺陷
描述清晰度
描述清楚
活跃度
活跃
技术栈
laravel, php
领域
api, backend

调研方向

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:

  1. ApiPlatform\Laravel\Eloquent\Metadata\Factory\Property\EloquentPropertyMetadataFactory wraps the native type of a plain column in Type::nullable() when the column is nullable (if ($p['nullable'])), but for a relation it always does Type::object($relation['related']) and never looks at the foreign key column. The property is therefore non-nullable, and AbstractItemNormalizer::createAndValidateAttributeValue() does not short-circuit on null (null === $value && $type->isNullable()), so it falls through to denormalizeRelation() which throws.
  2. Even if the type is made nullable by hand (nativeType: Type::nullable(Type::object(...)) on #[ApiProperty]), ApiPlatform\Laravel\Eloquent\State\PersistProcessor::process() does if (!isset($data->{$relation['name']})) { continue; }. A null relation is skipped, null stays as a raw Eloquent attribute named after the relation, and the insert/update fails with SQLSTATE[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).

  1. Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php: make an optional BelongsTo nullable, as already done for plain columns.

    Note that ModelMetadata::getAttributes() skips the BelongsTo foreign 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.)

  1. Eloquent/State/PersistProcessor.php: an explicit null on 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 / MorphTo on 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
平均合并
2 天 2 小时
30 天内合并 PR
101

环境准备

从这里开始

  1. 先读完整个 Issue,再读项目的贡献指南。
  2. 在 Issue 下留言说明你要接手 —— 这能避免两个人做同样的事。
  3. Fork 仓库,在一个分支上完成修改。
  4. 提交 Pull Request,并在描述里引用这个 Issue 编号。

api-platform/core 的其他 Issue

查看 api-platform/core 的全部 Issue

相似的 Issue

更多 PHP Issue

把新 issue 发到你的邮箱

精选适合新手参与的 GitHub issue 摘要。