软删除字段 deleted_at 默认参与唯一索引约束,因其存储具体时间而非 null,需通过数据库函数索引(mysql 8.0+)或部分索引(postgresql)实现仅对未删除记录生效的唯一性校验。

软删除字段 deleted_at 默认不参与唯一索引约束
这是最常被误解的一点:Laravel 的软删除只是给模型加了个 deleted_at 时间戳,并不会自动让数据库忽略已软删记录的唯一性检查。比如你建了 UNIQUE KEY `user_email_unique` (`email`),那即使某条记录 deleted_at 不为 NULL,再插入同邮箱的新记录仍会报 SQLSTATE[23000]: Integrity constraint violation。
根本原因在于——MySQL/PostgreSQL 的唯一索引默认对所有行生效,NULL 值在唯一索引中是允许重复的,但 Laravel 软删除写入的是具体时间(如 "2024-05-12 10:30:00"),不是 NULL,所以它会被当作普通非空值参与校验。
- 软删除后
deleted_at是 datetime 类型的具体时间,不是NULL - 唯一索引不区分“逻辑删除”和“物理存在”,只看字段值是否重复
- 想让它“不参与”,得靠数据库层面改造,不是靠 Laravel 模型配置
用函数索引(MySQL 8.0+)或部分索引(PostgreSQL)绕过已删除记录
核心思路是:把唯一性约束从「整列」变成「仅对未删除记录生效」。不同数据库实现方式不同,但目标一致——让已软删的行不进入索引范围。
MySQL 8.0+ 可用函数索引:
CREATE UNIQUE INDEX user_email_active_unique ON users (email) WHERE deleted_at IS NULL;
PostgreSQL 可用部分索引:
CREATE UNIQUE INDEX user_email_active_unique ON users (email) WHERE deleted_at IS NULL;
注意这两条语句长得像,但 MySQL 的 WHERE 是函数索引语法糖,PostgreSQL 的才是原生部分索引;低版本 MySQL(
- 执行前确保
deleted_at字段允许为NULL(Laravel 迁移里要用$table->softDeletes()->nullable()) - 旧数据里
deleted_at = '0000-00-00 00:00:00'或字符串'null'会导致索引失效,必须统一为NULL - 别名索引名要唯一,避免和 Laravel 自动迁移生成的索引冲突(比如不要叫
users_email_unique)
Laravel 迁移中怎么安全加这个索引
不能直接在 Schema::create() 里写,得用 DB::statement() 手动建,因为 Laravel Schema 构建器不支持部分/函数索引。
推荐写法(适配 MySQL 和 PostgreSQL):
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(MigrationBuilder $migration): void
{
if (DB::getDriverName() === 'mysql') {
DB::statement('CREATE UNIQUE INDEX user_email_active_unique ON users (email) WHERE deleted_at IS NULL');
} elseif (DB::getDriverName() === 'pgsql') {
DB::statement('CREATE UNIQUE INDEX user_email_active_unique ON users (email) WHERE deleted_at IS NULL');
}
}
public function down(MigrationBuilder $migration): void
{
DB::statement('DROP INDEX user_email_active_unique');
}
};
- 别在
up()里用Schema::table(...)->unique(...),它生成不了带WHERE条件的索引 - 如果项目同时跑 MySQL 和 PG,上线前务必确认对应库是否支持(MySQL
- 加索引前先清理脏数据:
UPDATE users SET deleted_at = NULL WHERE deleted_at = '0000-00-00 00:00:00';
查询时记得用 withTrashed() 或 onlyTrashed() 控制范围
索引改了,代码也得同步调整。否则会出现「数据库允许重复插入,但应用查不到刚插的软删记录」这种错位。
比如用户注册时校验邮箱是否已存在,应该用:
User::where('email', $email)->withoutTrashed()->exists()
而不是:
User::where('email', $email)->exists() // 默认就排除了软删记录,但得确认你没全局启用 withTrashed()
-
withoutTrashed()是默认行为,但如果你在模型里写了protected static $globalScopes = [...]或调过withTrashed(),就得显式恢复 - 后台管理可能需要查全部(含软删),那就用
withTrashed(),但别忘了前端表单提交时仍要跳过软删记录做唯一校验 - API 返回数据时,
deleted_at字段默认不会被隐藏,如果不想暴露,得在toArray()或资源类里手动 unset
真正麻烦的不是加索引,而是团队里有人忘了查数据时该不该包含软删记录,结果一个 find() 查到已注销用户,另一个 firstOrFail() 却抛出异常——边界模糊比语法错误更难 debug。











