From 5060909c37cedc4c55ed81b76a931e86f44345e8 Mon Sep 17 00:00:00 2001 From: Thomas Gnandt Date: Tue, 4 Aug 2026 14:27:14 +0200 Subject: [PATCH] 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) --- src/Console/ModelsCommand.php | 45 ++++++++++++++++++- .../Models/ModelWithRelations.php | 15 +++++++ ...estSoftDeletesForceNullableDisabled__1.php | 18 ++++++++ .../__snapshots__/Test__test__1.php | 18 ++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) diff --git a/src/Console/ModelsCommand.php b/src/Console/ModelsCommand.php index 9c65627..8e539d9 100644 --- a/src/Console/ModelsCommand.php +++ b/src/Console/ModelsCommand.php @@ -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 * diff --git a/tests/Console/ModelsCommand/SoftDeletesRelations/Models/ModelWithRelations.php b/tests/Console/ModelsCommand/SoftDeletesRelations/Models/ModelWithRelations.php index a368db8..687afa7 100644 --- a/tests/Console/ModelsCommand/SoftDeletesRelations/Models/ModelWithRelations.php +++ b/tests/Console/ModelsCommand/SoftDeletesRelations/Models/ModelWithRelations.php @@ -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'); diff --git a/tests/Console/ModelsCommand/SoftDeletesRelations/__snapshots__/Test__testSoftDeletesForceNullableDisabled__1.php b/tests/Console/ModelsCommand/SoftDeletesRelations/__snapshots__/Test__testSoftDeletesForceNullableDisabled__1.php index 16f1cd0..6760e30 100644 --- a/tests/Console/ModelsCommand/SoftDeletesRelations/__snapshots__/Test__testSoftDeletesForceNullableDisabled__1.php +++ b/tests/Console/ModelsCommand/SoftDeletesRelations/__snapshots__/Test__testSoftDeletesForceNullableDisabled__1.php @@ -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|ModelWithRelations newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ModelWithRelations newQuery() * @method static \Illuminate\Database\Eloquent\Builder|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'); diff --git a/tests/Console/ModelsCommand/SoftDeletesRelations/__snapshots__/Test__test__1.php b/tests/Console/ModelsCommand/SoftDeletesRelations/__snapshots__/Test__test__1.php index 77c51f0..14ea4c6 100644 --- a/tests/Console/ModelsCommand/SoftDeletesRelations/__snapshots__/Test__test__1.php +++ b/tests/Console/ModelsCommand/SoftDeletesRelations/__snapshots__/Test__test__1.php @@ -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|ModelWithRelations newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|ModelWithRelations newQuery() * @method static \Illuminate\Database\Eloquent\Builder|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');