软删除字段需加入联合唯一索引(如email+deleted_at)以保障未删除记录的唯一性,因MySQL中NULL不参与唯一校验;但SQLite不适用此方案,且restore前须手动检查冲突。

软删除字段必须加入唯一索引才能生效
默认 deleted_at 是 nullable 的,而 MySQL(尤其是 5.7+ 和 8.0)对含 NULL 值的字段在唯一索引中允许重复——这意味着你删掉一条 email=user@example.com 的记录后,还能再插入同名邮箱,违反业务唯一性约束。
解决办法不是绕开软删除,而是让唯一索引「忽略已删除记录」:把 deleted_at 加进联合唯一索引,利用 NULL 不参与唯一校验的特性。
- 迁移中定义联合唯一索引:
Schema::table('users', function (Blueprint $table) { $table->unique(['email', 'deleted_at']); }); - 已有表需先删旧索引(如仅
email唯一),再建新联合索引 - 注意:SQLite 行为不同(NULL 被视为相等),该方案不适用于 SQLite 测试环境
查询时别漏掉 withTrashed() 或 onlyTrashed()
加了联合索引后,email + deleted_at 的组合才真正唯一,但常规 User::where('email', $email)->first() 会跳过已软删除记录——这本身是预期行为;问题常出在注册/更新逻辑里误判“邮箱可用”。
- 判断邮箱是否已被占用(含已删除):用
User::withTrashed()->where('email', $email)->exists() - 但注册场景通常**只关心未删除的邮箱冲突**,所以应保持默认查询(不带软删除),否则会把“已删用户”也当成占用,导致无法复用邮箱
- 真正需要严格全局唯一(连已删记录也不许重用)?那就不能用软删除,得硬删或改用状态字段 + 普通唯一索引
restore() 时可能触发唯一索引冲突
当某条记录被软删除后,另一条同 email 的新记录被创建并保存,此时再调用 $user->restore() 就会报错:SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry。
- 这是联合索引生效的正常表现,不是 Bug
- 恢复前应手动检查冲突:
$existing = User::where('email', $user->email) ->whereNotNull('deleted_at') ->where('id', '!=', $user->id) ->first(); - 如有冲突,要么抛出业务异常,要么先软删新记录,再 restore 原记录
- Laravel 自带的
restore()不做预检,这点必须自己兜底
MySQL 版本与存储引擎的影响
该方案依赖 MySQL 对 NULL 的唯一索引处理逻辑,但不同版本细节有差异:
- MySQL 5.7+ InnoDB:NULL 值在唯一索引中彼此不冲突,方案安全
- MySQL 8.0+:行为一致,但若开启
innodb_strict_mode,部分边缘 case 会更早报错 - MyISAM 引擎不推荐——它对 NULL 的唯一性处理不可靠,且 Laravel 默认不用
- 务必在生产同版本 MySQL 上验证迁移和 restore 行为,本地用 Docker 起对应版本实例最稳妥
实际线上最容易翻车的,是开发时用 SQLite 跑测试、上线却跑 MySQL —— SQLite 把两个 NULL 当作相同值,联合唯一索引直接失效,测试完全覆盖不到冲突场景。











