Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle unique validation conflicts caused by soft-deleted records #54275

Draft
wants to merge 2 commits into
base: 11.x
Choose a base branch
from

Conversation

mohammadrasoulasghari
Copy link

Hi Laravel Team 👋,

During my experience working on various projects, I’ve encountered a recurring issue when validating the uniqueness of a field while using Soft Deletes. Specifically, when using Rule::unique for validation, it often leads to unintended conflicts because soft-deleted records are still considered in the validation check. This has caused confusion and bugs in projects where certain records are logically “deleted” but still exist in the database.

To address this issue, I’ve introduced a new method called withSoftDeletes to the Rule::unique class. This method allows developers to exclude soft-deleted records from the unique validation check. By explicitly opting in to this behavior, it ensures that the framework remains opinionated and avoids introducing any breaking changes.

I believe this addition can improve the developer experience by:

  1. Explicit is better than implicit” — This method makes the behavior clear and opt-in, avoiding unexpected side effects.
  2. Don’t repeat yourself (DRY)” — It reduces the repetitive task of manually adding conditions for soft-deleted records.

Usage Example:
Here’s an example of how this method can be used:

use Illuminate\Validation\Rule;

$request->validate([
    'email' => [
        'required',
        'email',
        Rule::unique('users', 'email')->withSoftDeletes(),
    ],
]);

Thank you for considering this PR! 😊

Looking forward to your feedback and suggestions.

Allows excluding soft-deleted records during unique validation.
public function withSoftDeletes()
{
$this->where(function ($query) {
$query->whereNull('deleted_at');
Copy link
Contributor

Choose a reason for hiding this comment

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

This field may be named differently in each model.

@mohammadrasoulasghari mohammadrasoulasghari force-pushed the feature/unique-soft-deletes branch from 6c8d962 to 358a8c2 Compare January 20, 2025 19:17
Ensure the soft delete column can be dynamically determined based on the model.
@mohammadrasoulasghari mohammadrasoulasghari force-pushed the feature/unique-soft-deletes branch from 358a8c2 to 3be1960 Compare January 20, 2025 19:19
@shaedrich
Copy link
Contributor

Doesn't DatabaseRule have a withoutTrashed() method?

/**
* Ignore soft deleted models during the existence check.
*
* @param string $deletedAtColumn
* @return $this
*/
public function withoutTrashed($deletedAtColumn = 'deleted_at')
{
$this->whereNull($deletedAtColumn);
return $this;
}

Comment on lines 85 to 93
$this->where(function ($query) use ($model) {
$deletedAtColumn = $model && method_exists($model, 'getDeletedAtColumn')
? $model->getDeletedAtColumn()
: 'deleted_at';

$query->whereNull($deletedAtColumn);
});

return $this;
Copy link
Contributor

Choose a reason for hiding this comment

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

You could use tap() here:

Suggested change
$this->where(function ($query) use ($model) {
$deletedAtColumn = $model && method_exists($model, 'getDeletedAtColumn')
? $model->getDeletedAtColumn()
: 'deleted_at';
$query->whereNull($deletedAtColumn);
});
return $this;
return tap($this)->where(function ($query) use ($model) {
$deletedAtColumn = $model && method_exists($model, 'getDeletedAtColumn')
? $model->getDeletedAtColumn()
: 'deleted_at';
$query->whereNull($deletedAtColumn);
});

* @return $this
*/
public function withSoftDeletes()
public function withSoftDeletes(?Model $model = null): static
Copy link
Contributor

Choose a reason for hiding this comment

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

You might want to add similar functionality to ignore(), where what is passed can be one of a few options:

public function ignore($id, $idColumn = null)
{
if ($id instanceof Model) {
return $this->ignoreModel($id, $idColumn);
}
$this->ignore = $id;
$this->idColumn = $idColumn ?? 'id';
return $this;
}

In our case, this would be either a model or the column name directly.

@taylorotwell taylorotwell marked this pull request as draft January 20, 2025 22:22
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.

3 participants