fix: Don't force |null on BelongsTo relations using ->withTrashed() (#1783)

When the related model uses SoftDeletes, `isRelationNullable()` was
forcing the BelongsTo relation type to nullable even if the relation
explicitly opted into trashed parents via `->withTrashed()`. Combined
with a NOT NULL FK column and DB-level FK constraint, the relation is
effectively non-nullable.

The SoftDeletes branch is now skipped when the relation has removed
the `SoftDeletingScope` and added no constraint on the qualified
`deleted_at` column. `->onlyTrashed()` and `->withoutTrashed()` keep
their nullable annotation because they restrict the parent to a
specific soft-delete state.

Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
Thomas Gnandt
2026-08-04 14:27:14 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 3a53071650
commit 5060909c37
4 changed files with 95 additions and 1 deletions
+44 -1
View File
@@ -44,6 +44,7 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\MorphToMany;
use Illuminate\Database\Eloquent\Relations\Pivot;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletingScope;
use Illuminate\Database\Schema\Builder;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
@@ -943,13 +944,55 @@ class ModelsCommand extends Command
}
}
if ($this->relatedModelUsesSoftDeletes($relationObj)) {
if (
$this->relatedModelUsesSoftDeletes($relationObj)
&& !$this->relationIncludesNonTrashedParents($relationObj)
) {
return true;
}
return false;
}
/**
* Check whether the relation explicitly opts into returning non-soft-deleted parents
* via ->withTrashed(), in which case the SoftDeletes-based nullability no longer applies.
*
* Returns false for ->onlyTrashed() and ->withoutTrashed(), which still leave the
* relation potentially empty depending on the parent's soft-delete state.
*
* @param Relation $relationObj
*
* @return bool
*/
protected function relationIncludesNonTrashedParents(Relation $relationObj): bool
{
$query = $relationObj->getQuery();
if (!in_array(SoftDeletingScope::class, $query->removedScopes(), true)) {
return false;
}
$relatedModel = $relationObj->getRelated();
if (!method_exists($relatedModel, 'getQualifiedDeletedAtColumn')) {
return true;
}
$deletedAtColumn = $relatedModel->getQualifiedDeletedAtColumn();
foreach ($query->getQuery()->wheres ?? [] as $where) {
if (
($where['column'] ?? null) === $deletedAtColumn
&& in_array($where['type'] ?? null, ['Null', 'NotNull'], true)
) {
return false;
}
}
return true;
}
/**
* Check if the related model uses the SoftDeletes trait
*