From c1b99ebc652c5a57ca06776df88d92bfdbd11b8e Mon Sep 17 00:00:00 2001 From: devtechedge Date: Sun, 11 Oct 2026 07:06:53 +0530 Subject: [PATCH 1/4] fix(laravel): accept null on an optional belongsTo --- .../EloquentPropertyMetadataFactory.php | 2 + .../Eloquent/Metadata/ModelMetadata.php | 38 +++++++ .../Eloquent/State/PersistProcessor.php | 12 +++ src/Laravel/Tests/Issue8664Test.php | 99 +++++++++++++++++++ .../app/Models/Issue8664Category.php | 25 +++++ .../workbench/app/Models/Issue8664Item.php | 31 ++++++ ...6_10_11_000000_create_issue8664_tables.php | 40 ++++++++ 7 files changed, 247 insertions(+) create mode 100644 src/Laravel/Tests/Issue8664Test.php create mode 100644 src/Laravel/workbench/app/Models/Issue8664Category.php create mode 100644 src/Laravel/workbench/app/Models/Issue8664Item.php create mode 100644 src/Laravel/workbench/database/migrations/2026_10_11_000000_create_issue8664_tables.php diff --git a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php index 0fcfa13776c..645969d8db9 100644 --- a/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php +++ b/src/Laravel/Eloquent/Metadata/Factory/Property/EloquentPropertyMetadataFactory.php @@ -118,6 +118,8 @@ public function create(string $resourceClass, string $property, array $options = $type = Type::object($relation['related']); if ($collection) { $type = Type::iterable($type); + } elseif (true === ($relation['nullable'] ?? false)) { + $type = Type::nullable($type); } return $propertyMetadata diff --git a/src/Laravel/Eloquent/Metadata/ModelMetadata.php b/src/Laravel/Eloquent/Metadata/ModelMetadata.php index 6477748f51d..0e529d37c3b 100644 --- a/src/Laravel/Eloquent/Metadata/ModelMetadata.php +++ b/src/Laravel/Eloquent/Metadata/ModelMetadata.php @@ -103,6 +103,8 @@ public function getAttributes(Model $model): array ]; } + $this->annotateBelongsToNullability($model, $columns); + $result = array_merge($attributes, $this->getVirtualAttributes($model, $columns)); // Don't cache an empty result for a missing table: the table may be created later @@ -116,6 +118,42 @@ public function getAttributes(Model $model): array return $this->attributes[$model::class] = $result; } + /** + * Records whether a BelongsTo foreign key column is nullable, using the same + * schema column flag as plain attributes. The foreign key name comes from the + * relation, not from a guessed column. + * + * @param list> $columns + */ + private function annotateBelongsToNullability(Model $model, array $columns): void + { + if (!isset($this->relations[$model::class])) { + return; + } + + $nullableByColumn = []; + foreach ($columns as $column) { + if (!isset($column['name'])) { + continue; + } + + $nullableByColumn[$column['name']] = (bool) $column['nullable']; + } + + foreach ($this->relations[$model::class] as $name => $relation) { + if (!isset($relation['type']) || !is_a($relation['type'], BelongsTo::class, true)) { + continue; + } + + $foreignKey = $relation['foreign_key'] ?? null; + if (!\is_string($foreignKey) || !\array_key_exists($foreignKey, $nullableByColumn)) { + continue; + } + + $this->relations[$model::class][$name]['nullable'] = $nullableByColumn[$foreignKey]; + } + } + /** * Counts the attribute reads that found no table. Metadata computed while this number * changes was built from a missing table, and must not be cached beyond the current call. diff --git a/src/Laravel/Eloquent/State/PersistProcessor.php b/src/Laravel/Eloquent/State/PersistProcessor.php index af5eb28660a..93786c9577c 100644 --- a/src/Laravel/Eloquent/State/PersistProcessor.php +++ b/src/Laravel/Eloquent/State/PersistProcessor.php @@ -44,6 +44,18 @@ public function process(mixed $data, Operation $operation, array $uriVariables = $toMany = []; foreach ($this->modelMetadata->getRelations($data) as $relation) { + // isset() is false for null, so an explicit null would be skipped and saved as a column named after the relation. + if ( + (BelongsTo::class === $relation['type'] || MorphTo::class === $relation['type']) + && \array_key_exists($relation['name'], $data->getAttributes()) + && null === $data->getAttributes()[$relation['name']] + ) { + $data->{$relation['method_name']}()->dissociate(); + unset($data->{$relation['name']}); + + continue; + } + if (!isset($data->{$relation['name']})) { continue; } diff --git a/src/Laravel/Tests/Issue8664Test.php b/src/Laravel/Tests/Issue8664Test.php new file mode 100644 index 00000000000..b1f2e9d260a --- /dev/null +++ b/src/Laravel/Tests/Issue8664Test.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests; + +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Workbench\App\Models\Issue8664Item; + +/** + * @see https://github.com/api-platform/core/issues/8664 + */ +final class Issue8664Test extends TestCase +{ + use RefreshDatabase; + use WithWorkbench; + + /** + * @see https://github.com/api-platform/core/issues/8664 + */ + public function testPostWithExplicitNullBelongsToDoesNotError(): void + { + $response = $this->postJson('/api/issue8664_items', [ + 'code' => 'a', + 'category' => null, + ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + + $response->assertStatus(201); + $this->assertNull(Issue8664Item::query()->findOrFail($response->json('id'))->category_id); + } + + /** + * @see https://github.com/api-platform/core/issues/8664 + */ + public function testPatchWithExplicitNullClearsBelongsTo(): void + { + $category = $this->postJson('/api/issue8664_categories', [ + 'name' => 'tools', + ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + $category->assertStatus(201); + + $created = $this->postJson('/api/issue8664_items', [ + 'code' => 'a', + 'category' => $category->json('@id'), + ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + $created->assertStatus(201); + + $item = Issue8664Item::query()->findOrFail($created->json('id')); + $this->assertNotNull($item->category_id); + + $response = $this->patchJson($created->json('@id'), [ + 'category' => null, + ], ['accept' => 'application/ld+json', 'content-type' => 'application/merge-patch+json']); + + $response->assertStatus(200); + $this->assertNull($item->fresh()->category_id); + } + + /** + * @see https://github.com/api-platform/core/issues/8664 + */ + public function testOmittingBelongsToDoesNotClearIt(): void + { + $category = $this->postJson('/api/issue8664_categories', [ + 'name' => 'tools', + ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + $category->assertStatus(201); + + $created = $this->postJson('/api/issue8664_items', [ + 'code' => 'a', + 'category' => $category->json('@id'), + ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + $created->assertStatus(201); + + $item = Issue8664Item::query()->findOrFail($created->json('id')); + $categoryId = $item->category_id; + $this->assertNotNull($categoryId); + + $response = $this->patchJson($created->json('@id'), [ + 'code' => 'b', + ], ['accept' => 'application/ld+json', 'content-type' => 'application/merge-patch+json']); + + $response->assertStatus(200); + $item->refresh(); + $this->assertSame($categoryId, $item->category_id); + $this->assertSame('b', $item->code); + } +} diff --git a/src/Laravel/workbench/app/Models/Issue8664Category.php b/src/Laravel/workbench/app/Models/Issue8664Category.php new file mode 100644 index 00000000000..3766cffde9f --- /dev/null +++ b/src/Laravel/workbench/app/Models/Issue8664Category.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\Models; + +use ApiPlatform\Metadata\ApiResource; +use Illuminate\Database\Eloquent\Model; + +#[ApiResource] +class Issue8664Category extends Model +{ + protected $table = 'issue8664_categories'; + + protected $fillable = ['name']; +} diff --git a/src/Laravel/workbench/app/Models/Issue8664Item.php b/src/Laravel/workbench/app/Models/Issue8664Item.php new file mode 100644 index 00000000000..31d1f20cadc --- /dev/null +++ b/src/Laravel/workbench/app/Models/Issue8664Item.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\Models; + +use ApiPlatform\Metadata\ApiResource; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +#[ApiResource] +class Issue8664Item extends Model +{ + protected $table = 'issue8664_items'; + + protected $fillable = ['code']; + + public function category(): BelongsTo + { + return $this->belongsTo(Issue8664Category::class); + } +} diff --git a/src/Laravel/workbench/database/migrations/2026_10_11_000000_create_issue8664_tables.php b/src/Laravel/workbench/database/migrations/2026_10_11_000000_create_issue8664_tables.php new file mode 100644 index 00000000000..07b1328c611 --- /dev/null +++ b/src/Laravel/workbench/database/migrations/2026_10_11_000000_create_issue8664_tables.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration { + public function up(): void + { + Schema::create('issue8664_categories', static function (Blueprint $table): void { + $table->id(); + $table->string('name'); + $table->timestamps(); + }); + + Schema::create('issue8664_items', static function (Blueprint $table): void { + $table->id(); + $table->string('code'); + $table->foreignId('category_id')->nullable()->constrained('issue8664_categories')->nullOnDelete(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('issue8664_items'); + Schema::dropIfExists('issue8664_categories'); + } +}; From d66a171e42d286c9458292db2903477a1da20a22 Mon Sep 17 00:00:00 2001 From: devtechedge Date: Mon, 12 Oct 2026 21:50:22 +0530 Subject: [PATCH 2/4] fix(laravel): compute belongsTo nullability with the relations Move the test onto the existing DeliveryRequest::pickupTimeSlot workbench relation. --- .../Eloquent/Metadata/ModelMetadata.php | 49 +++------ src/Laravel/Tests/Issue8664Test.php | 99 ------------------- src/Laravel/Tests/NullableBelongsToTest.php | 81 +++++++++++++++ .../workbench/app/Models/DeliveryRequest.php | 13 +++ .../app/Models/Issue8664Category.php | 25 ----- .../workbench/app/Models/Issue8664Item.php | 31 ------ ...6_10_11_000000_create_issue8664_tables.php | 40 -------- 7 files changed, 105 insertions(+), 233 deletions(-) delete mode 100644 src/Laravel/Tests/Issue8664Test.php create mode 100644 src/Laravel/Tests/NullableBelongsToTest.php delete mode 100644 src/Laravel/workbench/app/Models/Issue8664Category.php delete mode 100644 src/Laravel/workbench/app/Models/Issue8664Item.php delete mode 100644 src/Laravel/workbench/database/migrations/2026_10_11_000000_create_issue8664_tables.php diff --git a/src/Laravel/Eloquent/Metadata/ModelMetadata.php b/src/Laravel/Eloquent/Metadata/ModelMetadata.php index 0e529d37c3b..436e258a721 100644 --- a/src/Laravel/Eloquent/Metadata/ModelMetadata.php +++ b/src/Laravel/Eloquent/Metadata/ModelMetadata.php @@ -103,8 +103,6 @@ public function getAttributes(Model $model): array ]; } - $this->annotateBelongsToNullability($model, $columns); - $result = array_merge($attributes, $this->getVirtualAttributes($model, $columns)); // Don't cache an empty result for a missing table: the table may be created later @@ -118,42 +116,6 @@ public function getAttributes(Model $model): array return $this->attributes[$model::class] = $result; } - /** - * Records whether a BelongsTo foreign key column is nullable, using the same - * schema column flag as plain attributes. The foreign key name comes from the - * relation, not from a guessed column. - * - * @param list> $columns - */ - private function annotateBelongsToNullability(Model $model, array $columns): void - { - if (!isset($this->relations[$model::class])) { - return; - } - - $nullableByColumn = []; - foreach ($columns as $column) { - if (!isset($column['name'])) { - continue; - } - - $nullableByColumn[$column['name']] = (bool) $column['nullable']; - } - - foreach ($this->relations[$model::class] as $name => $relation) { - if (!isset($relation['type']) || !is_a($relation['type'], BelongsTo::class, true)) { - continue; - } - - $foreignKey = $relation['foreign_key'] ?? null; - if (!\is_string($foreignKey) || !\array_key_exists($foreignKey, $nullableByColumn)) { - continue; - } - - $this->relations[$model::class][$name]['nullable'] = $nullableByColumn[$foreignKey]; - } - } - /** * Counts the attribute reads that found no table. Metadata computed while this number * changes was built from a missing table, and must not be cached beyond the current call. @@ -247,6 +209,7 @@ public function getRelations(Model $model): array } $relations = []; + $nullableColumns = null; $class = new \ReflectionClass($model); foreach ($class->getMethods() as $method) { @@ -300,6 +263,16 @@ public function getRelations(Model $model): array 'related' => \get_class($relation->getRelated()), 'foreign_key' => method_exists($relation, 'getForeignKeyName') ? $relation->getForeignKeyName() : null, ]; + + if ($relation instanceof BelongsTo) { + $nullableColumns ??= array_column($model->getConnection()->getSchemaBuilder()->getColumns($model->getTable()), 'nullable', 'name'); + $relations[$relationName]['nullable'] = (bool) ($nullableColumns[$relation->getForeignKeyName()] ?? false); + } + } + + // Don't cache nullability read from a missing table, see getAttributes(). + if ([] === $nullableColumns) { + return $relations; } return $this->relations[$model::class] = $relations; diff --git a/src/Laravel/Tests/Issue8664Test.php b/src/Laravel/Tests/Issue8664Test.php deleted file mode 100644 index b1f2e9d260a..00000000000 --- a/src/Laravel/Tests/Issue8664Test.php +++ /dev/null @@ -1,99 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Laravel\Tests; - -use Illuminate\Foundation\Testing\RefreshDatabase; -use Orchestra\Testbench\Concerns\WithWorkbench; -use Orchestra\Testbench\TestCase; -use Workbench\App\Models\Issue8664Item; - -/** - * @see https://github.com/api-platform/core/issues/8664 - */ -final class Issue8664Test extends TestCase -{ - use RefreshDatabase; - use WithWorkbench; - - /** - * @see https://github.com/api-platform/core/issues/8664 - */ - public function testPostWithExplicitNullBelongsToDoesNotError(): void - { - $response = $this->postJson('/api/issue8664_items', [ - 'code' => 'a', - 'category' => null, - ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); - - $response->assertStatus(201); - $this->assertNull(Issue8664Item::query()->findOrFail($response->json('id'))->category_id); - } - - /** - * @see https://github.com/api-platform/core/issues/8664 - */ - public function testPatchWithExplicitNullClearsBelongsTo(): void - { - $category = $this->postJson('/api/issue8664_categories', [ - 'name' => 'tools', - ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); - $category->assertStatus(201); - - $created = $this->postJson('/api/issue8664_items', [ - 'code' => 'a', - 'category' => $category->json('@id'), - ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); - $created->assertStatus(201); - - $item = Issue8664Item::query()->findOrFail($created->json('id')); - $this->assertNotNull($item->category_id); - - $response = $this->patchJson($created->json('@id'), [ - 'category' => null, - ], ['accept' => 'application/ld+json', 'content-type' => 'application/merge-patch+json']); - - $response->assertStatus(200); - $this->assertNull($item->fresh()->category_id); - } - - /** - * @see https://github.com/api-platform/core/issues/8664 - */ - public function testOmittingBelongsToDoesNotClearIt(): void - { - $category = $this->postJson('/api/issue8664_categories', [ - 'name' => 'tools', - ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); - $category->assertStatus(201); - - $created = $this->postJson('/api/issue8664_items', [ - 'code' => 'a', - 'category' => $category->json('@id'), - ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); - $created->assertStatus(201); - - $item = Issue8664Item::query()->findOrFail($created->json('id')); - $categoryId = $item->category_id; - $this->assertNotNull($categoryId); - - $response = $this->patchJson($created->json('@id'), [ - 'code' => 'b', - ], ['accept' => 'application/ld+json', 'content-type' => 'application/merge-patch+json']); - - $response->assertStatus(200); - $item->refresh(); - $this->assertSame($categoryId, $item->category_id); - $this->assertSame('b', $item->code); - } -} diff --git a/src/Laravel/Tests/NullableBelongsToTest.php b/src/Laravel/Tests/NullableBelongsToTest.php new file mode 100644 index 00000000000..64d64ebd1de --- /dev/null +++ b/src/Laravel/Tests/NullableBelongsToTest.php @@ -0,0 +1,81 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests; + +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Workbench\App\Models\DeliveryRequest; +use Workbench\Database\Factories\TimeSlotFactory; + +/** + * @see https://github.com/api-platform/core/issues/8664 + */ +final class NullableBelongsToTest extends TestCase +{ + use RefreshDatabase; + use WithWorkbench; + + public function testPostWithExplicitNullBelongsTo(): void + { + $response = $this->postJson('/api/delivery_requests', [ + 'pickupTimeSlot' => null, + 'note' => 'no slot', + ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + + $response->assertStatus(201); + $this->assertNull(DeliveryRequest::query()->sole()->pickup_time_slot_id); // @phpstan-ignore-line + } + + public function testPatchWithExplicitNullClearsBelongsTo(): void + { + $iri = $this->createDeliveryRequestWithSlot(); + + $response = $this->patchJson($iri, [ + 'pickupTimeSlot' => null, + ], ['accept' => 'application/ld+json', 'content-type' => 'application/merge-patch+json']); + + $response->assertStatus(200); + $this->assertNull(DeliveryRequest::query()->sole()->pickup_time_slot_id); // @phpstan-ignore-line + } + + public function testOmittingBelongsToKeepsIt(): void + { + $iri = $this->createDeliveryRequestWithSlot(); + $slotId = DeliveryRequest::query()->sole()->pickup_time_slot_id; // @phpstan-ignore-line + + $response = $this->patchJson($iri, [ + 'note' => 'updated', + ], ['accept' => 'application/ld+json', 'content-type' => 'application/merge-patch+json']); + + $response->assertStatus(200); + $deliveryRequest = DeliveryRequest::query()->sole(); + $this->assertSame($slotId, $deliveryRequest->pickup_time_slot_id); // @phpstan-ignore-line + $this->assertSame('updated', $deliveryRequest->note); // @phpstan-ignore-line + } + + private function createDeliveryRequestWithSlot(): string + { + $slot = TimeSlotFactory::new()->create(); + + $response = $this->postJson('/api/delivery_requests', [ + 'pickupTimeSlot' => '/api/time_slots/'.$slot->id, // @phpstan-ignore-line + 'note' => 'with slot', + ], ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']); + $response->assertStatus(201); + $this->assertNotNull(DeliveryRequest::query()->sole()->pickup_time_slot_id); // @phpstan-ignore-line + + return $response->json('@id'); + } +} diff --git a/src/Laravel/workbench/app/Models/DeliveryRequest.php b/src/Laravel/workbench/app/Models/DeliveryRequest.php index e79e35d23d7..bfbad142f5b 100644 --- a/src/Laravel/workbench/app/Models/DeliveryRequest.php +++ b/src/Laravel/workbench/app/Models/DeliveryRequest.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -35,6 +36,18 @@ ], ] ), + new Patch( + normalizationContext: [ + 'groups' => [ + 'delivery_request:read', + ], + ], + denormalizationContext: [ + 'groups' => [ + 'delivery_request:write', + ], + ] + ), ] )] #[ApiProperty(property: 'pickupTimeSlot', serialize: new Groups(['delivery_request:read', 'delivery_request:write']))] diff --git a/src/Laravel/workbench/app/Models/Issue8664Category.php b/src/Laravel/workbench/app/Models/Issue8664Category.php deleted file mode 100644 index 3766cffde9f..00000000000 --- a/src/Laravel/workbench/app/Models/Issue8664Category.php +++ /dev/null @@ -1,25 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace Workbench\App\Models; - -use ApiPlatform\Metadata\ApiResource; -use Illuminate\Database\Eloquent\Model; - -#[ApiResource] -class Issue8664Category extends Model -{ - protected $table = 'issue8664_categories'; - - protected $fillable = ['name']; -} diff --git a/src/Laravel/workbench/app/Models/Issue8664Item.php b/src/Laravel/workbench/app/Models/Issue8664Item.php deleted file mode 100644 index 31d1f20cadc..00000000000 --- a/src/Laravel/workbench/app/Models/Issue8664Item.php +++ /dev/null @@ -1,31 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace Workbench\App\Models; - -use ApiPlatform\Metadata\ApiResource; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Eloquent\Relations\BelongsTo; - -#[ApiResource] -class Issue8664Item extends Model -{ - protected $table = 'issue8664_items'; - - protected $fillable = ['code']; - - public function category(): BelongsTo - { - return $this->belongsTo(Issue8664Category::class); - } -} diff --git a/src/Laravel/workbench/database/migrations/2026_10_11_000000_create_issue8664_tables.php b/src/Laravel/workbench/database/migrations/2026_10_11_000000_create_issue8664_tables.php deleted file mode 100644 index 07b1328c611..00000000000 --- a/src/Laravel/workbench/database/migrations/2026_10_11_000000_create_issue8664_tables.php +++ /dev/null @@ -1,40 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -use Illuminate\Database\Migrations\Migration; -use Illuminate\Database\Schema\Blueprint; -use Illuminate\Support\Facades\Schema; - -return new class extends Migration { - public function up(): void - { - Schema::create('issue8664_categories', static function (Blueprint $table): void { - $table->id(); - $table->string('name'); - $table->timestamps(); - }); - - Schema::create('issue8664_items', static function (Blueprint $table): void { - $table->id(); - $table->string('code'); - $table->foreignId('category_id')->nullable()->constrained('issue8664_categories')->nullOnDelete(); - $table->timestamps(); - }); - } - - public function down(): void - { - Schema::dropIfExists('issue8664_items'); - Schema::dropIfExists('issue8664_categories'); - } -}; From 085ee44f6a66434f5c7c44987fc0003b3d944a3f Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 13 Oct 2026 16:01:33 +0200 Subject: [PATCH 3/4] test(laravel): cover null on an optional morphTo --- src/Laravel/Tests/NullableBelongsToTest.php | 18 +++++++ .../app/Models/NullableMorphNote.php | 51 +++++++++++++++++++ .../factories/NullableMorphNoteFactory.php | 29 +++++++++++ ...0000_create_nullable_morph_notes_table.php | 33 ++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 src/Laravel/workbench/app/Models/NullableMorphNote.php create mode 100644 src/Laravel/workbench/database/factories/NullableMorphNoteFactory.php create mode 100644 src/Laravel/workbench/database/migrations/2026_10_13_000000_create_nullable_morph_notes_table.php diff --git a/src/Laravel/Tests/NullableBelongsToTest.php b/src/Laravel/Tests/NullableBelongsToTest.php index 64d64ebd1de..d90bea255d2 100644 --- a/src/Laravel/Tests/NullableBelongsToTest.php +++ b/src/Laravel/Tests/NullableBelongsToTest.php @@ -17,6 +17,8 @@ use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; use Workbench\App\Models\DeliveryRequest; +use Workbench\App\Models\NullableMorphNote; +use Workbench\Database\Factories\NullableMorphNoteFactory; use Workbench\Database\Factories\TimeSlotFactory; /** @@ -65,6 +67,22 @@ public function testOmittingBelongsToKeepsIt(): void $this->assertSame('updated', $deliveryRequest->note); // @phpstan-ignore-line } + public function testPatchWithExplicitNullClearsMorphTo(): void + { + $slot = TimeSlotFactory::new()->create(); + $note = NullableMorphNoteFactory::new()->for($slot, 'noteable')->create(); + $this->assertNotNull(NullableMorphNote::query()->sole()->noteable_id); // @phpstan-ignore-line + + $response = $this->patchJson('/api/nullable_morph_notes/'.$note->id, [ // @phpstan-ignore-line + 'noteable' => null, + ], ['accept' => 'application/ld+json', 'content-type' => 'application/merge-patch+json']); + + $response->assertStatus(200); + $note = NullableMorphNote::query()->sole(); + $this->assertNull($note->noteable_id); // @phpstan-ignore-line + $this->assertNull($note->noteable_type); // @phpstan-ignore-line + } + private function createDeliveryRequestWithSlot(): string { $slot = TimeSlotFactory::new()->create(); diff --git a/src/Laravel/workbench/app/Models/NullableMorphNote.php b/src/Laravel/workbench/app/Models/NullableMorphNote.php new file mode 100644 index 00000000000..5d3a804c375 --- /dev/null +++ b/src/Laravel/workbench/app/Models/NullableMorphNote.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\Models; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\MorphTo; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + operations: [ + new Get( + normalizationContext: ['groups' => ['nullable_morph_note:read']], + ), + new Post( + normalizationContext: ['groups' => ['nullable_morph_note:read']], + denormalizationContext: ['groups' => ['nullable_morph_note:write']], + ), + new Patch( + normalizationContext: ['groups' => ['nullable_morph_note:read']], + denormalizationContext: ['groups' => ['nullable_morph_note:write']], + ), + ] +)] +#[ApiProperty(property: 'noteable', serialize: new Groups(['nullable_morph_note:read', 'nullable_morph_note:write']))] +#[ApiProperty(property: 'content', serialize: new Groups(['nullable_morph_note:read', 'nullable_morph_note:write']))] +class NullableMorphNote extends Model +{ + use HasFactory; + + public function noteable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/src/Laravel/workbench/database/factories/NullableMorphNoteFactory.php b/src/Laravel/workbench/database/factories/NullableMorphNoteFactory.php new file mode 100644 index 00000000000..84d96d1c674 --- /dev/null +++ b/src/Laravel/workbench/database/factories/NullableMorphNoteFactory.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\Database\Factories; + +use Illuminate\Database\Eloquent\Factories\Factory; +use Workbench\App\Models\NullableMorphNote; + +class NullableMorphNoteFactory extends Factory +{ + protected $model = NullableMorphNote::class; + + public function definition(): array + { + return [ + 'content' => $this->faker->sentence(), + ]; + } +} diff --git a/src/Laravel/workbench/database/migrations/2026_10_13_000000_create_nullable_morph_notes_table.php b/src/Laravel/workbench/database/migrations/2026_10_13_000000_create_nullable_morph_notes_table.php new file mode 100644 index 00000000000..3daf1ff7c33 --- /dev/null +++ b/src/Laravel/workbench/database/migrations/2026_10_13_000000_create_nullable_morph_notes_table.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration { + public function up(): void + { + Schema::create('nullable_morph_notes', static function (Blueprint $table): void { + $table->id(); + $table->nullableMorphs('noteable'); + $table->string('content')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('nullable_morph_notes'); + } +}; From 6d3e26c2e51834e1ea97b586e8067e24299ecd61 Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 13 Oct 2026 16:01:33 +0200 Subject: [PATCH 4/4] doc: changelog note for nullable belongsTo --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e223feed83..c5203e1344a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v5.0.4 + +### Notes + +* Laravel: an optional `BelongsTo` or `MorphTo` relation (nullable foreign key column) is now typed as nullable, so it accepts `null` on write and clears the relation (#8664). This changes the generated docs for every nullable `BelongsTo`: JSON Schema and OpenAPI mark it nullable, and GraphQL drops the `!` on these fields, inputs included. + ## v5.0.3 API Platform 5.0.3 contains every change shipped in [v4.4.4](#v444) except the revert of #8600 ([#8637](https://github.com/api-platform/core/pull/8637)): 5.0 keeps documenting filterless array query parameters as both `key` and `key[]`, now guarded against `key[][]`, plus the changes below.