CatchAdmin PHP 后台管理框架 Logo CatchAdmin

软删除这一 PHP 常见模式正在悄悄腐蚀你的数据

一位用户请求注销账号。应用调用 $user->delete(),接口返回 204,用户满意离开。

那一行数据其实还在。只是多了一个 deleted_at = '2026-05-19 14:32:11' 而已,其它每一个字节都停在原地——邮箱、哈希密码、2019 年写的让人后悔的个人简介、最近一次登录的 IP。从应用视角看,这位用户消失了;从数据库视角看,没有任何东西被删除。

这就是软删除。Laravel 里通过 SoftDeletes trait 默认提供;Symfony Doctrine 里通过 Gedmo 的 SoftDeleteable 扩展实现。它作为"绝不要真的删数据"这种老派经验法则,在 PHP 社区流传了十多年。

它也是最被误用的模式之一。并不是它在原则上有错——确有场景适合——而是它往往被惯性地反手拿来用,被贴在并不需要它的表上,最后制造出比它所要解决的问题更难调试的新问题。

TL;DR 速览

  • 软删除不删除任何东西。它只是翻一个 deleted_at 标记,然后依靠之后所有查询都自觉地把这些行过滤掉。
  • 隐性代价集中在三处:唯一约束被打破,JOIN 返回幽灵数据,以及"删除我的账号"请求根本没做真正的删除,无法满足 GDPR。
  • Laravel 的 SoftDeletes trait 只在"走 Eloquent"时才起作用。DB::table()、原生 JOIN、子查询以及任何绕过模型的代码都会跳过全局作用域,默默读到被"删除"的行。
  • 对于绝大多数"以后也许想恢复"的场景,一张独立的归档表 + 真删除,代码更干净、查询更快、对数据正确性更友好。
  • 如果软删除确实是对的选择(财务记录、监管留存、复杂撤销流程),就必须从一开始就照着软删除设计——部分唯一索引、配套的外键策略、以及明确的"谁在什么时候读取被删数据"的规则。

本文要点

  • 软删除实际会发出哪些 SQL 操作,以及数据库里哪些东西其实没变
  • 软删除带来的五类典型 bug,附真实可复现的示例
  • 为什么"以后想恢复"多数时候不是采用软删除的好理由
  • 更干净的替代方案——删除日志、归档表、事件溯源——各自适用的场景
  • 当软删除不再值得时,如何安全地把一张表迁回硬删除

软删除实际做了什么

模式本身很简洁,分两半。删除时,ORM 不发 DELETE FROM users WHERE id = ?,而是发 UPDATE users SET deleted_at = NOW() WHERE id = ?。此后每一次针对该表的查询都会通过全局作用域在 WHERE 子句末尾追加 AND deleted_at IS NULL

Laravel 的写法:

php
class User extends Model {
    use SoftDeletes;
}

$user = User::find(1);
$user->delete();  // UPDATE users SET deleted_at = '2026-05-19 14:32:11' WHERE id = 1

User::find(1);    // 返回 null——全局作用域把这行过滤掉了
User::all();      // 不包含被删除的用户
User::withTrashed()->find(1);  // 绕过作用域,返回模型

这就是契约。写起来干净、读起来顺畅,在简单场景下也能正常工作。

麻烦从"离开简单场景"的第一刻开始。

Bug 1:唯一约束不认 deleted_at

这是最常见、最尴尬、并且几乎总是在生产环境被真实用户第一个触发的 bug。

php
Schema::create('users', function (Blueprint $table) {
    $table->id();
    $table->string('email')->unique();
    $table->softDeletes();
});

一位用户用 alice@example.com 注册,三个月后决定注销。应用调用 $user->delete(),Eloquent 把 deleted_at 翻起来。对应用来说 Alice 消失了。

一周后 Alice 反悔,准备用同一个邮箱重新注册。表单验证通过,User::create([...]) 执行,数据库拒绝:

SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'alice@example.com' for key 'users.users_email_unique'

数据库层的 UNIQUE 约束作用于表中的每一行,而不是只作用于 deleted_at IS NULL 的那部分。那位"已删除"的 Alice 仍占着这个邮箱,除非有人手动清掉记录,Alice 再也没法用这个邮箱注册。

网上教程常见的 Laravel 侧绕法是自定义验证规则:

php
'email' => ['required', 'email', Rule::unique('users')->whereNull('deleted_at')],

验证确实会过,但它并没有修复数据库侧的约束。验证通过之后 INSERT 仍然会被数据库拒绝。这条验证规则更像一张写着"小心碰头"的警示贴,贴在一堵用户马上要撞上的墙上。

真正的修复在 schema 层。PostgreSQL 支持部分唯一索引:

sql
CREATE UNIQUE INDEX users_email_unique ON users(email) WHERE deleted_at IS NULL;

这样唯一性只作用在未删除的行上。Alice 那条被删除的记录不再计入约束,重新注册可以成功,而同一邮箱再出现第二条活跃记录仍会被拒绝。PostgreSQL 原生支持;SQL Server 通过带过滤条件的索引(filtered index)实现了相同能力;MySQL 没有部分唯一索引,只能用生成列或组合索引把 deleted_at 塞进唯一键,写起来更丑,还有自己的边界问题。

一旦采用软删除,就必须对每一张软删除表上的每一个唯一列都记得做这件事。大部分代码库都做不到这点,这个 bug 会潜伏着,直到某个用户做了开发者没想到的事。

Bug 2:关联表与 JOIN 会看到已亡的数据

还有一个更微妙的问题。用于 belongsToMany 关系的 pivot 表,几乎从不自带 deleted_at 列。当被关联的模型被软删除时,指向它的 pivot 行仍然留在那里。

以典型的角色-权限结构为例:

php
class User extends Model {
    public function roles() { return $this->belongsToMany(Role::class); }
}

class Role extends Model {
    use SoftDeletes;
}

Alice 拥有 editor 角色。管理员把 editor 这个角色本身软删除了("我们要合并为 admin 和 viewer")。Alice 会怎样?

通过 Eloquent 问 $alice->roles,Laravel 会在 JOIN 里加上 roles.deleted_at IS NULL,于是 editor 角色不会出现,Alice 名下没有角色。

但如果有人对 role_user 写一段原生分析查询,那条关联记录仍在。一个按凌晨任务跑的"无角色用户"统计,直接数 pivot 行和通过关系数得到的结果不会一致。两种世界视图会出现漂移。

用真实数据库验证一下:三条 pivot 记录(Alice→admin、Alice→editor、Bob→editor),editor 被软删除后:

sql
-- 类 Eloquent 的"正确"查询:Alice 只有 1 个角色(admin)
SELECT COUNT(*)
FROM users u
LEFT JOIN role_user ru ON u.id = ru.user_id
LEFT JOIN roles r ON ru.role_id = r.id AND r.deleted_at IS NULL
WHERE u.id = 1;
-- → 1

-- 朴素的原生查询:Alice 有 2 个角色(把孤儿行也算上了)
SELECT COUNT(*) FROM role_user WHERE user_id = 1;
-- → 2

两条查询在数据库层面都"对"。它们只是回答了两个不同的问题,而被回答的是哪一个,取决于写查询的开发者是否记得带上软删除作用域。

修复方式要么是用完善的外键级联(但它和软删除本身并不好搭配,见 Bug 3),要么是在每条分析查询里手工带上作用域 JOIN,要么干脆就不在软删除表上用 belongsToMany。没有一个选项令人愉快。

Bug 3:外键对此毫不知情

数据库外键的存在是为了防止出现悬挂引用。如果执行 DELETE FROM users WHERE id = 1 时还有 posts 指向用户 1,数据库要么拒绝删除,要么级联删除(取决于约束定义)。

而软删除是一条 UPDATE,不是 DELETE。用户行仍然在,外键觉得一切正常,因为那行确实存在。应用视角里用户 1 已经消失,数据库视角里用户 1 很健康。

sql
INSERT INTO users (id, email) VALUES (1, 'alice@example.com');
INSERT INTO posts (user_id, title) VALUES (1, 'Hello');

UPDATE users SET deleted_at = '2025-01-01' WHERE id = 1;
-- 外键未违反,因为这一行仍然存在

SELECT * FROM posts WHERE user_id = 1;
-- → 1 行,指向一个"已删除"的用户

在 Eloquent 里访问 $post->user 会返回 null,因为全局作用域把已删除用户挡掉了。于是帖子的 user_id 指向一个走关系时会变成 null 的位置,它处于半态:外键有值,值指向某处,但那个某处对应用不可见。

正确处理了这种情况的代码(if ($post->user) { ... })能正常工作;没处理的那些会崩:

blade
{{-- Blade 里很常见的写法 --}}
{{ $post->user->name }}
// → ErrorException: Attempt to read property "name" on null

崩的不是直接访问被软删用户的代码,而是访问其关联模型的代码。"谁引用了它,谁就要意识到软删除"的连锁反应,会扩散得比最初那次变更想象的更远。

级联这里也救不了场。ON DELETE CASCADE 只在真实的 DELETE 语句上触发,对改写 deleted_atUPDATE 无效。软删除没有内置级联;Laravel 的 trait 不会把删除自动传播到相关表。需要在应用层写级联逻辑(在 Model::deleting 事件里递归软删子记录),而且要在每一处关联里都记得这样写。

Bug 4:全局作用域并不普适

软删除整套正确性假设,依赖的是"WHERE deleted_at IS NULL 一直都被自动加上"。Laravel 的全局作用域只对走 Eloquent 的查询有效,对绕开 Eloquent 的查询无效。

已验证的绕过场景:

php
// 绕过 1:查询构造器不知道 Eloquent 的作用域
DB::table('users')->get();
// → 已删除用户也会返回

// 绕过 2:原生 JOIN 不会过滤被连接的表
DB::table('posts')
    ->join('users', 'users.id', '=', 'posts.user_id')
    ->select('posts.title', 'users.email')
    ->get();
// → 会返回通过已软删用户连接到的帖子

// 绕过 3:子查询丢失作用域
User::whereIn('id', DB::table('users')->select('id'))->get();
// → 内层子查询没有作用域;外层有

// 绕过 4:withCount 作用在非软删子模型上时
User::withCount('logEntries')->get();
// → 对 log_entries 的 COUNT(*) 没有 deleted_at 过滤
//   即便语义上它原本该被过滤

// 绕过 5:任何原生 SQL
DB::select('SELECT * FROM users WHERE created_at > ?', [$cutoff]);
// → 返回已删除用户

只要代码库稍具规模,上面这些写法里总有几个会出现。报表用原生查询写,是因为 Eloquent 不易表达那种 JOIN;后台任务用查询构造器,是为了性能;分析面板直接连库。这些都是软删除行会"漏"回应用视图的地方,而写这些查询的开发者常常并没考虑这件事——因为最初给表加软删除的也不是他们。

漏点通常是沉默的。没有异常,没有 500。只是某些没有自动化测试的地方,数据稍稍错了一点点。

Bug 5:GDPR 与删除权

2016 年的 GDPR(以及加州、巴西乃至全球多数地区的同类法律)赋予用户"要求删除其个人数据"的权利。这条权利是具体的,监管机构也不是在闹着玩。

软删除无法满足这一要求。数据本身仍在。任何有数据库访问权限的人——现任员工、未来员工、攻破备份的攻击者——都能读到邮箱、地址、消息记录、IP 日志。应用 UI 把它们从查询里遮住,并不等于把它们从数据里删除,只是让它们更难被看到。

当用户按 GDPR 要求注销账号时,"我们把 deleted_at 置起来了"不是合规答复。数据必须被真正删除,或匿名化到无法再与该个人关联。很多应用团队都是在法务要求演示删除流程、发现数据从数据库里轻易就能完全复原的那一刻,才意识到这件事。

务实的做法要么是硬删除含个人信息的列(保留一条记录删除发生过的审计日志,而不是保留被删除的内容),要么是就地匿名化——把邮箱替换为 deleted-user-{id}@example.invalid,把姓名清空,对 IP 做哈希。两者都需要实打实的工程投入,单靠软删除替代不了。

软删除为何仍然流行

这种模式的吸引力确实存在。第一,"后悔药"——User::onlyTrashed()->find(1)->restore() 在误操作之后是心理慰藉;第二,审计视角——知道一条记录曾经存在、何时消失,对某些数据有价值;第三,便利——加上 use SoftDeletes; 是一行代码,而设计一套完整的归档系统是真正的工程。

这些好处都真实存在。问题是:对具体某张表而言,它们是否足以压过上面五类 bug。多数表的诚实答案是"不"。恢复大概每季度需要一次,而且通常从备份就能完成;审计用真正的审计日志更好——它能记录"谁在何时做了什么",而不仅仅是"这行曾经存在、现在不在了";"便利"其实是在借一笔没感觉到的利息。

更清爽的模式,删除日志

在大部分被惯性采用软删除的场景里,更合适的答案是"真删除 + 一张独立的删除日志表":

sql
CREATE TABLE users (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    email VARCHAR(255) NOT NULL UNIQUE,
    -- 无 deleted_at 列,删除就是真删除
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE user_deletions (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    original_user_id BIGINT NOT NULL,
    email VARCHAR(255) NOT NULL,
    snapshot_json JSON NOT NULL,
    deleted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    deleted_by BIGINT NULL,
    reason VARCHAR(255) NULL,
    INDEX (original_user_id),
    INDEX (deleted_at)
);

删除动作放在事务里完成:

php
DB::transaction(function () use ($userId, $reason) {
    $user = User::findOrFail($userId);

    UserDeletion::create([
        'original_user_id' => $user->id,
        'email'            => $user->email,
        'snapshot_json'    => $user->toJson(),
        'deleted_by'       => auth()->id(),
        'reason'           => $reason,
    ]);

    $user->delete();  // 这次是真正的 DELETE
});

这样做相比软删除获得了几件事:主表上的唯一约束按预期工作、外键级联自然正常、对 users 的查询不再需要 deleted_at 过滤、审计表提供的信息比软删除更丰富——谁执行了删除、为什么、当时那一行包含什么。

恢复也变成显式动作:UserDeletion::find($id)->restore() 从快照重新写入,而不是意外地"撤销"。审计日志可以按与主表不同的策略独立保留——这正是 GDPR 要求主数据被清除、但审计"确实删除过"本身可以保留的常见需求。

这种模式与数据库原生级联行为天然组合。用户被删除时,相关记录可以被合理处置:帖子可以通过 ON DELETE SET NULL 的外键指向一个"已删除用户"占位,子表如 session 可以通过 ON DELETE CASCADE 自动清理,全部由数据库强制执行,不需要应用层代码记挂。

对照一下已验证的软删除行为:

相同邮箱删除后重新注册:
  - 软删除 + 常规 UNIQUE:被拒(约束冲突)
  - 软删除 + 部分唯一索引(Postgres):可以
  - 硬删除 + 日志:可以(无约束冲突,审计保留)

删除日志在落地那一刻确实比软删除多写一些代码,但它多出的是"说啥就做啥"的代码。软删除看似少代码,可它做的事情和大多数开发者心中预想的并不完全一致。

软删除真正适用的场景

确实存在。只是比业界常识暗示的范围要窄,但真实存在。

关联密集、恢复频繁的关系型数据。 用户经常删除任务又在几分钟内恢复的项目管理工具;编辑者会在删除后数周内恢复文章的 CMS 平台。"硬删除 + 日志"模式要求显式地重新插入一整棵关联数据,对拥有十几个相关表的实体而言成本不低。软删除则把所有数据留在原处,恢复只是翻一个标记。

监管留存要求。 某些行业(金融记录、医疗记录、部分政府场景)要求数据在用户"删除"后仍按特定期限留存。此时软删除恰好匹配语义——从用户视角数据不见了,但依法必须让机构仍能访问。

只追加的领域模型。 事件溯源系统和会计分录通常根本不删除,而是通过补偿条目来"抵消"。如果领域模型本身已经偏事件化,更合适的方向通常是彻底往事件溯源那一端走,而不是停在半途。

在这些场景里,软删除所带来的摩擦是值得的。需要做的是彻底承担这套模式:从第一天起就按部分唯一索引来设计 schema;所有分析查询都显式写上 JOIN 条件;把"软删除状态"视作领域中一个一等公民。当软删除是被"设计进去"而非"随手加上去"时,它的工作是没问题的。麻烦的是,极少有代码库是以这种姿态采用软删除的——trait 被加上去,往往只是因为谁读了某篇教程,而不是因为谁做了一次设计决策。

三个自检问题

在给某张表加软删除——或保留它——之前,值得先问三个问题。

恢复场景是否具体? "以后也许想恢复"是氛围,"编辑者会在 30 天内定期恢复文章,并且有专门的回收站界面"才是需求。没有文档化的恢复流程、也没有历史使用记录时,这个场景就只是假设,而夜间备份对假设型恢复的总体成本要低得多。

审计场景是否具体? "想知道什么被删过"同样是氛围。像样的审计日志记录的内容远比软删除多——谁执行的、何时、为何、被删那一行是什么内容,以及此前的修改。软删除单独给的信息只停留在"这一行曾经存在、现在不再存在"。

读/删比如何? 一张每天被读几百万次、每年才删两次的表,每天都在为"deleted_at IS NULL 过滤 + 相关索引考量"付出代价,却几乎拿不到收益。删除少、恢复更少的表是软删除最不合适的场景,每日在付费却从未兑现。

三个问题中有两个答案指向"不需要",这层模式就在白白制造摩擦。去掉它能让 schema 更干净、查询更快、bug 面更小。

把软删除退回硬删除

如果某张表当前在用软删除但其实不该用,迁移路径并不复杂,只是需要小心。

第一步是数据分诊。已经在那里放了好几年的软删除记录,通常可以直接硬删除——没人会去恢复一行从 2019 年就"删除"的数据。相对近期的软删除则值得迁到删除日志表再硬删。介于"老得可以直接丢弃"和"近得值得归档"之间的那条线,通常要由业务决定;6 个月是一个常用分界。

sql
-- 把近期软删除行归档到新的日志表
INSERT INTO user_deletions (original_user_id, email, snapshot_json, deleted_at)
SELECT id, email,
       JSON_OBJECT('id', id, 'email', email, 'created_at', created_at),
       deleted_at
FROM users
WHERE deleted_at IS NOT NULL
  AND deleted_at > NOW() - INTERVAL 6 MONTH;

-- 硬删除所有软删除行
DELETE FROM users WHERE deleted_at IS NOT NULL;

-- 等列空出来且不再被引用,把它丢掉
ALTER TABLE users DROP COLUMN deleted_at;

在 Laravel 里把模型上的 SoftDeletes trait 去掉是简单的部分。难的是找出代码库中所有使用 withTrashed()onlyTrashed()restore() 的地方再改写。静态分析工具(PHPStan、Psalm)能捕获多数场景,因为 trait 被去掉后这些方法不再存在,IDE 也会在所有引用处标红。

通常会有一两个地方事后才发现是"承重墙"——后台的"已删除用户"列表页、CMS 里的"恢复文章"按钮。这些地方要改为从删除日志表读。这是真正的工作量,但它是可界定的工作量,改完之后系统更容易推理。

需要避开的陷阱

在没有部分唯一索引的表上直接加 SoftDeletes 如果表上有任何唯一约束,必须在同一次变更里补上部分唯一索引。否则,第一位删除并尝试重新创建的用户就会撞上约束冲突。这是生产环境里 Laravel 软删除最常见的 bug。

忘了在反向关系上加 withTrashed()Post 关联到一个软删除的 User 时,对于作者已被软删除的帖子,$post->user 返回 null。认为用户一定存在的代码会抛异常。要么在需要被删父对象时显式 withTrashed(),要么让默认关系取值容忍 null

在软删除表上使用 firstOrCreate() firstOrCreate(['email' => 'x']) 无法匹配到已被软删除的记录(被全局作用域遮住了),于是尝试新建一行——这一行又会撞到唯一约束。修法是 withTrashed()->firstOrCreate(...),并在命中被删记录时显式 restore()——但方法名并不暗示这种语义,因此这类 bug 经常出现。

DB::table() 读取时没意识到会读到被删行。 查询构造器调用会绕过全局作用域。用查询构造器写的报表、导出、后台工具默认会读到软删除行。要么手动 JOIN 上 deleted_at IS NULL,要么把所有需要遵守作用域的逻辑都交给 Eloquent。

误以为 withCount 会过滤被统计的关系。 User::withCount('orders') 会生成对 ordersCOUNT(*) 子查询。如果 Order 自身没有使用 SoftDeletes,计数就会包含语义上应被"删除"的订单。子查询只继承内层模型的软删除作用域,外层的无法替代。

在 pivot 表上使用软删除却没处理重新关联。 如果 role_user 自己带软删除,用户解除角色后又重新关联,新的 pivot 行会与软删除那行撞上。多数尝试这么做的代码库最后都会陷入奇怪的状态,因为该模式与 attach() / detach() 组合得并不好。

简明 Q&A

是不是就不应该用软删除?
不必。某些场景下它是对的选择。门槛在于:恢复、审计或留存需求是否真实且足够频繁,值得承担相应复杂度。对绝大多数应用的多数表而言,答案是否定的;对特定表(财务记录、带编辑者回收站的内容、受监管数据)而言,答案是肯定的。把软删除当通用默认值、而不是按表做决策,才是真正的错误。

Doctrine 的 SoftDeleteable 扩展呢?
上述五类 bug 几乎原样适用。Doctrine 扩展的实现是在每次查询上挂 filter 而非全局作用域,但同样存在"只对 ORM 查询生效、不对原生 SQL 生效"的问题。数据库层的唯一约束表现完全一样。如果正在 Doctrine 中考虑软删除,同样的三个自检问题依旧适用,"删除日志"的替代方案同样奏效。

如何实现删除日志而不破坏引用完整性?
删除日志里的 original_user_id 是一个普通整数列,而不是外键——它所引用的行已经不存在了,做外键会违反约束。如果需要从被删记录的子表回溯到归档,一种干净的做法是把子表的外键设为 NULLON DELETE SET NULL),同时在子表里新增一个 archived_parent_id 列保存原父 ID。子记录在活表里仍然合法,只是链接断开,需要回溯时由应用层通过归档表接续。

这套分析同样适用于 is_active 这类布尔标记吗?
是的,只是有一些差别。is_active = false 有着与 deleted_at 完全相同的问题:唯一约束不知情、JOIN 默认不过滤,等等。差别在于 is_active = false 通常具备明确的语义("这行存在但当前被禁用"),而 deleted_at 伪装成"这行不存在"。如果模型里确实有一种"禁用但未删除"的状态,使用布尔标记是合理的——只要把 schema(部分唯一索引、外键约束)同样设计成对它有感知。

结语

软删除不是邪恶的,它是一种比被使用的范围更窄的工具。当恢复、审计场景真实、高频且足够重要、以至于从第一天起就会围绕它设计 schema 时,软删除能正常工作。当它是被惯性添加、被当作免费升级、被期望像"真删除"那样行事,但数据库却并不这么认为时,它就会出问题。

自检方法很简单:把代码库里所有软删除表列出来,对每一张自问:恢复场景真的发生了吗?审计场景是否值得它带来的成本?如果两者答案都是模糊的,这个软删除就是只有开销没有收益的摩擦,移除它能让 schema 更简洁、查询更快、bug 面更小。

对于答案明确为"是"的那些表,就把软删除做透。加上部分唯一索引、在分析查询里显式 JOIN 作用域、文档化"谁在何时读取被删行",把软删除状态视为领域里的一等状态。

真正危险的不是软删除本身,而是"习惯性地加上它,然后再也不回头问一句它是否配得上那块位置"。

收束闭环

一位用户请求注销账号。应用调用 $user->delete(),接口返回 204,用户满意离开。

这一次,删除是真的。个人数据消失了。user_deletions 表里留下一行:用户 47281 于 14:32:11 按请求被删除,邮箱和 ID 留作审计线索,其它什么也没留。一周后他用同一邮箱再次注册,一次就通过了。半年后 GDPR 合规审查要证据证明账号删除确为实质删除时,审计查询给出干净的答案。

用户不会注意到这其中的任何细节,也不必注意。系统就是按用户一开始预期的那样运作。

这正是全部的意义所在。

"相关问答" 8 问

1. Laravel 中的软删除是什么?
软删除是一种模式,删除一条记录时把 deleted_at 设为当前时间戳,而不是真正从表里移除。Laravel 通过 SoftDeletes trait 实现:它注入一个全局作用域过滤掉 deleted_at IS NOT NULL 的行,并把模型的 delete() 改写为发出 UPDATE 而非 DELETE。同时为处理已删除行提供了 withTrashed()onlyTrashed()restore() 等方法。

2. 软删除为什么会破坏唯一约束?
数据库级唯一约束作用于表中的所有行,与 deleted_at 是否被设置无关。当一条记录被软删除、随后又写入一条同值的新记录时,约束会拒绝新记录,即便旧记录在应用视角已经"不存在"。PostgreSQL 和 SQL Server 支持部分唯一索引(UNIQUE INDEX ... WHERE deleted_at IS NULL)解决这个问题;MySQL 不直接支持,需要用生成列等变通方案。

3. 软删除符合 GDPR 吗?
单靠它本身不符合。GDPR 的删除权要求个人数据被真正移除或匿名化。仅翻一个标记的软删除把所有个人数据原封不动地留在库里,任何有数据库访问权限的人都能读到。合规的删除要么硬删除含个人信息的列,要么将其替换为匿名化值。删除发生的事实可以通过删除日志单独记录,但不应再保留被删的个人数据本身。

4. 软删除和删除日志有什么区别?
软删除把原始记录留在活跃表里、通过 deleted_at 标记加以区分,要求所有查询都记得过滤这个标记。删除日志把"被删除"这件事迁移到一张独立的审计表,原始记录被真正删除,活跃表保持干净,审计线索独立存放。删除日志的方案通常更干净,因为它不与唯一约束、外键或全局作用域冲突。

5. 如何防止全局作用域被绕过?
诚实的答案是无法彻底防。任何使用 DB::table()、原生 SQL 或查询构造器 JOIN 的代码都会绕过 Eloquent 全局作用域、看到软删除行。可采取的缓解包括:对原生查询做代码评审、用静态分析规则对软删除表上的查询构造器使用做告警、通过仓储或模型架构约束所有访问走统一入口。没有一种是万全方案。

6. 是否可以安全地给现有表加上软删除?
增加列是安全的——它是可空时间戳,不影响既有数据。但一旦在模型上启用 trait,整套代码对该模型的查询行为会立刻改变:每一条查询都会追加 WHERE deleted_at IS NULL。原本行为正确的代码可能在新行为下出问题。建议在独立分支上启用 trait 并配合完备的测试覆盖,在合并前确认查询返回的计数仍符合预期。

7. 软删除和 is_activearchived 这类标记是一回事吗?
机制上是的。它们共享同一类隐形代价:唯一约束不知情、JOIN 默认不过滤、全局作用域(若有)容易被绕过。语义上则不同——is_active = false 通常表示"存在但当前未启用",deleted_at 暗示"应视作已消失"。bug 模式相同,选择哪一个应当根据领域语义真正对应哪一种。

8. 如何把软删除级联到相关模型?
Laravel 本身不会自动级联。常见做法是在父模型上注册 deleting 事件监听器递归软删除关联模型,再配一个 restoring 监听器做恢复。这种做法脆弱——日后新增一个关系就得记得扩展级联——并且不能与数据库自身的外键级联组合。真正需要可靠级联的应用,用硬删除配 ON DELETE CASCADE 外键会更简单、更正确。

说明:本文中的 schema 和查询示例均针对真实数据库做过验证。SoftDeletes trait 行为以 Laravel 11.x 源码(Illuminate\Database\Eloquent\SoftDeletes)为准。部分唯一索引语法以 PostgreSQL 为例展示;SQL Server 的 filtered index 等价;MySQL 8.0 不原生支持部分唯一索引,需要用生成列等变通方案。具体合规要求因司法辖区和用例而异,本文为工程层面的建议,不构成法律意见。

本作品采用《CC 协议》,转载必须注明作者和本文链接