Skip to content

fix(laravel): accept null on an optional belongsTo - #8666

Open
devtechedge wants to merge 2 commits into
api-platform:4.4from
devtechedge:fix-laravel-nullable-belongsto
Open

devtechedge wants to merge 2 commits into
api-platform:4.4from
devtechedge:fix-laravel-nullable-belongsto

Conversation

@devtechedge

Copy link
Copy Markdown

Thanks for the report.

An optional BelongsTo whose foreign key is nullable should accept an explicit null, the same way Doctrine already does.

EloquentPropertyMetadataFactory wraps a nullable plain column with Type::nullable(), but a relation always became Type::object() and never looked at the foreign key.

I mark that relation nullable when the foreign key column is nullable, using the column name the relation already reports.

PersistProcessor skipped a null relation because isset() is false for null, so the null stayed as a raw attribute and the insert looked for a column named after the relation.

A present null on BelongsTo or MorphTo now calls dissociate() and unsets that attribute before save.

Omitting the key still leaves the stored relation alone.

I ran the filtered Issue8664Test on PHP 8.4, and POST and PATCH with the relation set to null no longer 500, with the foreign key null afterward.

Fixes #8664

@soyuka
soyuka force-pushed the fix-laravel-nullable-belongsto branch from f6995ca to c1b99eb Compare October 12, 2026 13:14
@soyuka
soyuka changed the base branch from 5.0 to 4.4 October 12, 2026 13:14

@soyuka soyuka left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks for the PR, a few update suggestions.

];
}

$this->annotateBelongsToNullability($model, $columns);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

getRelations() now only knows about nullable if getAttributes() ran before it. It's the case today (the property factory and api-platform:metadata:dump call attributes first) but nothing enforces it, if someone reorders these calls every relation silently becomes non-nullable again. Can you compute it where the relations are built so there's no ordering at all?

Comment on lines +121 to +146
/**
* 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<array<string, mixed>> $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;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

A few guards here can't happen: getAttributes() calls getRelations() first so $this->relations[$model::class] is always set ([] at worst), $column['name'] and $relation['type'] are always there (the code above uses them without checks). Can we remove these? Also please drop the docblock, the @param list<...> can stay if phpstan needs it.

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'])

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

the MorphTo branch isn't covered, dissociate() also nulls the type column there. Could you add a test for it?

Comment thread src/Laravel/Tests/Issue8664Test.php Outdated
/**
* @see https://github.com/api-platform/core/issues/8664
*/
final class Issue8664Test extends TestCase

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We already have a nullable BelongsTo in the workbench: DeliveryRequest::pickupTimeSlot (pickup_time_slot_id is nullable). Could you use it instead of the two new models + migration? Bonus: the relation is camelCase so it also tests the method_name (used by dissociate()) vs snake_case name (used by the unset) split in the processor, category can't catch that. Then move the tests into EloquentTest or a file with a descriptive name (NullableBelongsToTest?), and the @see link once is enough. Thanks!

$type = Type::object($relation['related']);
if ($collection) {
$type = Type::iterable($type);
} elseif (true === ($relation['nullable'] ?? false)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Note that this changes the output for every existing nullable BelongsTo: JSON Schema/OpenAPI become nullable and GraphQL drops the ! on these fields (inputs included). It's the right fix and it matches what Doctrine does but we need to mention this in the changelog.

Move the test onto the existing DeliveryRequest::pickupTimeSlot workbench relation.
@devtechedge

Copy link
Copy Markdown
Author

Thanks for the careful review, these all make the change tighter 👍

Nullability is now computed inside getRelations(), so there is no ordering between it and getAttributes() anymore.

The helper with the extra guards and its docblock is gone.

The tests now live in NullableBelongsToTest and use DeliveryRequest::pickupTimeSlot, so the two Issue8664 models and the migration are removed.

I added a Patch operation to DeliveryRequest so the clear and keep cases go through the real endpoint.

For the MorphTo test, comments_morph uses non-nullable morph columns, so would you rather I switch it to nullableMorphs or add a small dedicated model?

For the changelog, should the BC note about nullable schemas and the GraphQL ! go in the PR description or in the commit body?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants