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
*
@@ -17,6 +17,21 @@ class ModelWithRelations extends Model
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id');
}
public function softDeletableWithTrashed(): BelongsTo
{
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->withTrashed();
}
public function softDeletableOnlyTrashed(): BelongsTo
{
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->onlyTrashed();
}
public function softDeletableWithoutTrashed(): BelongsTo
{
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->withoutTrashed();
}
public function nonSoftDeletable(): BelongsTo
{
return $this->belongsTo(NonSoftDeletableModel::class, 'non_soft_deletable_model_id');
@@ -16,6 +16,9 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\NonSoftDeletableModel|null $nonSoftDeletableHasOne
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel $softDeletable
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel|null $softDeletableHasOne
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel $softDeletableOnlyTrashed
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel $softDeletableWithTrashed
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel $softDeletableWithoutTrashed
* @method static \Illuminate\Database\Eloquent\Builder<static>|ModelWithRelations newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ModelWithRelations newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ModelWithRelations query()
@@ -33,6 +36,21 @@ class ModelWithRelations extends Model
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id');
}
public function softDeletableWithTrashed(): BelongsTo
{
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->withTrashed();
}
public function softDeletableOnlyTrashed(): BelongsTo
{
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->onlyTrashed();
}
public function softDeletableWithoutTrashed(): BelongsTo
{
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->withoutTrashed();
}
public function nonSoftDeletable(): BelongsTo
{
return $this->belongsTo(NonSoftDeletableModel::class, 'non_soft_deletable_model_id');
@@ -16,6 +16,9 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\NonSoftDeletableModel|null $nonSoftDeletableHasOne
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel|null $softDeletable
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel|null $softDeletableHasOne
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel|null $softDeletableOnlyTrashed
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel $softDeletableWithTrashed
* @property-read \Barryvdh\LaravelIdeHelper\Tests\Console\ModelsCommand\SoftDeletesRelations\Models\SoftDeletableModel|null $softDeletableWithoutTrashed
* @method static \Illuminate\Database\Eloquent\Builder<static>|ModelWithRelations newModelQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ModelWithRelations newQuery()
* @method static \Illuminate\Database\Eloquent\Builder<static>|ModelWithRelations query()
@@ -33,6 +36,21 @@ class ModelWithRelations extends Model
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id');
}
public function softDeletableWithTrashed(): BelongsTo
{
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->withTrashed();
}
public function softDeletableOnlyTrashed(): BelongsTo
{
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->onlyTrashed();
}
public function softDeletableWithoutTrashed(): BelongsTo
{
return $this->belongsTo(SoftDeletableModel::class, 'soft_deletable_model_id')->withoutTrashed();
}
public function nonSoftDeletable(): BelongsTo
{
return $this->belongsTo(NonSoftDeletableModel::class, 'non_soft_deletable_model_id');