# ThinkPHP 模型事件
## 背景
商品删除不能是真删除——因为历史订单需要关联展示已删除的商品信息。设置某个状态字段标记商品不可见,而非物理删除记录。
隐藏带来了新问题:**怎么把商品"取消隐藏"?**
最直觉的写法是:
```php
// 查出来 → 改状态 → 保存
$goods = Goods::find($id);
$goods->status = 1;
$goods->save();
```
## 模型事件
ThinkPHP 的模型在生命周期节点会自动触发"钩子",需要在对应方法里写逻辑,框架会在恰当的时机调用:
| 事件 | 触发时机 |
|------|----------|
| `before_insert` | INSERT 之前 |
| `after_insert` | INSERT 之后 |
| `before_update` | UPDATE 之前 |
| `after_update` | UPDATE 之后 |
| `before_write` | 任意写入之前(INSERT / UPDATE) |
| `after_write` | 任意写入之后(INSERT / UPDATE) |
| `before_delete` | DELETE 之前 |
| `after_delete` | DELETE 之后 |
**使用场景**:状态变更记录日志、自动清除关联缓存、同步冗余字段、触发下游业务……任何"数据变了就跟着变"的逻辑,都可以放在事件里。
## 哪些写法能触发模型事件
| 写法 | 触发事件 |
|------|----------|
| `Model->create($data)` | ✅ `before_insert` / `after_insert` / `before_write` / `after_write` |
| `Model->saveAll($list)` | ✅ 每个模型独立触发事件(详见第六节) |
| `Model->destroy($id)` | ✅ `before_delete` / `after_delete` |
| 先 `find()` 再改属性 `->save()` | ✅ `before_update` / `after_update` / `before_write` / `after_write` |
| `where()->update(['field' => 'val'])` | ❌ **不触发任何事件**(这是查询构造器,不是模型操作) |
### 源码级分析
`save()` 方法的完整流程:
```
save()
│
├─ ① trigger('BeforeWrite') ← 无条件触发,永远执行
│
├─ ② validateAndFilterData() ← dirty 检测:值未变的字段被剔除
│
├─ ③ if (empty($data)) return true; ← 如果$data为空,直接返回,后续事件不触发
│
├─ ④ autoWriteData() ← 关键!自动注入 update_time
│
├─ ⑤ db->save($data) ← 执行 SQL
│
├─ ⑥ trigger('AfterUpdate') ← 触发
├─ ⑦ trigger('AfterWrite') ← 触发
│
└─ ⑧ refreshOrigin()
```
**关键机制**:
`validateAndFilterData` 中的 dirty 检测:
```php
protected function isNotRequireUpdate(string $name, $val, array $origin): bool
{
return (array_key_exists($name, $origin) && $val === $origin[$name])
|| $this->getPk() == $name;
}
```
如果业务数据没有变化,过滤后 `$data = []`,按理说在步骤 ③ 就会 `return true`,后续事件不会触发。
**但是**——`autoWriteData()` 在步骤 ④ 注入了 `update_time`:
```php
protected function autoDateTime(array &$data, bool $update, array $allow)
{
if ($autoDateTime) {
$dateTimeFields = [$this->getOption('updateTime')];
foreach ($dateTimeFields as $field) {
if (is_string($field) && empty($data[$field]) ...) {
$data[$field] = $this->getDateTime($field);
}
}
}
}
```
注入后 `$data = ['update_time' => 1722141600]`,`empty($data)` 为 `false`,流程继续,事件触发,SQL 执行。
### 结论
> **不是因为"update_time 变了所以检测到变化"触发事件,而是 `autoWriteTimestamp` 在 dirty 检测之后、数据库写入之前自动注入 `update_time`,使 `$data` 不为空,绕过了提前返回机制,保证事件必然触发。**
### 不开 `autoWriteTimestamp` 会怎样?
| 事件 | 数据无变化时是否触发 |
|------|:---:|
| `before_write` | ✅ 会触发(无条件触发,在 dirty 检测之前) |
| `before_update` / `after_update` | ❌ 不会触发 |
| `after_write` | ❌ 不会触发 |
因为 `autoDateTime` 不注入 `update_time`,`$data` 为空,步骤 ③ 直接 `return true`,后续代码不执行。
### 唯一例外:强制写入
```php
$model->force()->save(); // 或 $model->isForce(true)->save()
```
`force(true)` 会跳过 dirty 检测,所有字段都保留在 `$data` 中,即使没有 `autoWriteTimestamp` 也会触发事件。
## 总结对比
| 场景 | `before_write` | `before_update`/`after_update` | SQL 执行 |
|------|:---:|:---:|:---:|
| `save()` 业务数据有变化 | ✅ | ✅ | ✅ |
| `save()` 业务数据无变化 + `autoWriteTimestamp` | ✅ | ✅ | ✅(至少更新 update_time) |
| `save()` 业务数据无变化 + **无** `autoWriteTimestamp` | ✅ | ❌ | ❌ |
| `force()->save()` | ✅ | ✅ | ✅ |
| `where()->update()` | ❌ | ❌ | ✅ |
## saveAll 为什么特殊
### 源码
```php
public static function saveAll(iterable $dataSet, bool $replace = true): Collection
{
$result = [];
$model = new static;
$pk = $model->getPk();
foreach ($dataSet as $key => $data) {
$model = new static; // 每条记录一个全新实例
if ($replace) {
$exists = true;
foreach ((array) $pk as $field) {
if (is_string($field) && !isset($data[$field])) {
$exists = false;
}
}
} else {
$exists = false;
}
$model->replace($replace)->exists($exists)->save($data);
$result[$key] = $model->fetchModel($model);
}
return $model->toCollection($result);
}
```
---
#### 1. 没有事务包裹——部分成功不回滚
`saveAll` 就是一个 `for` 循环,逐条调用 `save()`。**没有 `Db::startTrans()`**。
```php
foreach ($dataSet as $key => $data) {
$model->save($data); // 如果中间某条失败,前面的已经提交了
}
```
这意味着:
- 前 3 条成功,第 4 条因为唯一索引冲突挂了 → **前 3 条已经写入,不会回滚**
- 如果你需要原子性,必须自己包事务:
```php
Db::startTrans();
try {
Model::saveAll($data);
Db::commit();
} catch (\Exception $e) {
Db::rollback();
}
```
---
#### 2. 每条记录独立触发事件
```php
foreach ($dataSet as $key => $data) {
$model = new static; // ← 每条记录都是全新的模型实例
$model->save($data); // ← 每条都独立走一遍 save() 的完整流程
}
```
结果:**每条记录独立触发一套事件**。
插入 5 条数据 → `before_insert` / `after_insert` 各触发 5 次。
更新 3 条数据 → `before_update` / `after_update` 各触发 3 次。
而如果你用 `where()->update()` 批量改 100 条,0 次事件触发。
---
#### 3. 混合操作——一次调用里同时 INSERT 和 UPDATE
`saveAll` 不是纯批量插入也不是纯批量更新,它是**逐条判断**:
```php
if ($replace) {
$exists = true;
foreach ((array) $pk as $field) {
if (!isset($data[$field])) {
$exists = false; // 主键不全 → 当插入处理
}
}
} else {
$exists = false; // replace=false → 全部当插入
}
```
| 场景 | `$replace` | 数据是否含完整主键 | 行为 |
|------|:-:|:-:|------|
| 批量插入 | `true` | ❌ | INSERT |
| 批量更新 | `true` | ✅ | UPDATE |
| 批量插入(强制) | `false` | 无视 | INSERT |
| 混合操作 | `true` | 有的有不有 | 有主键的 UPDATE,没主键的 INSERT |
所以你可以同时传入:
```php
Goods::saveAll([
['name' => '新商品', 'price' => 10], // 没主键 → INSERT
['id' => 5, 'name' => '改名的老商品'], // 有主键 → UPDATE
]);
```
一条语句,混合 INSERT + UPDATE,每条各自触发对应的事件。
---
#### 4. `$replace` 参数决定 SQL 方言
```php
$model->replace($replace)->save($data);
```
`replace(true)` 时,**INSERT 语句会变成 `REPLACE INTO`**:
```sql
-- replace=false(默认的 insert)
INSERT INTO goods (name, price) VALUES ('abc', 10)
-- replace=true
REPLACE INTO goods (id, name, price) VALUES (5, 'abc', 10)
-- ↑ 如果 id=5 已存在,先删除旧行再插入新行
```
`REPLACE INTO` 的行为:
- 主键/唯一索引不冲突 → 普通 INSERT
- 冲突 → **DELETE 旧行 + INSERT 新行**
⚠️ **注意**:`REPLACE INTO` 的"删除"是真删除,会触发 `before_delete` / `after_delete` 事件(旧行),然后 INSERT 触发 `before_insert` / `after_insert`(新行)。所以 replace 更新实际是 **delete 事件 + insert 事件**,不是 update 事件。
---
#### 5. 每条记录都 `new static`——状态完全隔离
```php
foreach ($dataSet as $key => $data) {
$model = new static; // 全新实例
// ...
}
```
每次循环都是全新的模型实例,`origin`(原始数据)、`exists`(是否存在标志)、`dirty`(脏数据)全部从零开始。
**不会互相污染**——第 2 条记录不会因为第 1 条改了某个静态属性而受影响。
但反过来也意味着:
- **无法跨记录共享上下文**(比如第 1 条生成的 ID 想让第 2 条用,做不到)
- 每轮 `fetchModel()` 都会重新包装返回实体类
---
### saveAll 最终对比
| 特性 | `save()` | `saveAll()` | `where()->update()` |
|------|----------|-------------|---------------------|
| 触发事件 | ✅ 每条一套 | ✅ 每元素一套 | ❌ 不触发 |
| 事务 | 无 | 无(需手动包) | 无 |
| INSERT/UPDATE 判断 | 单条判断 | 逐条独立判断 | 纯 UPDATE |
| SQL 方言 | INSERT / UPDATE | INSERT / UPDATE / REPLACE | UPDATE |
| 适用场景 | 单条 CRUD | 批量导入、批量替换 | 批量改字段 |
**`saveAll` = "把批量操作拆成 N 次单条 `save()`",换取"每条都走事件机制"的能力,代价是**没有原子性保证**。
AfterRead = “数据从数据库加载到模型时触发”,不是”保存时触发”。它和 AfterWrite 是一对——一个管读,一个管写
find-查不到不触发
where()->find()-查不到不触发
select-每一条数据都触发一次
注意
protected function isNotRequireUpdate(string $name, $val, array $origin): bool
{
return (array_key_exists($name, $origin) && $val === $origin[$name]) || $this->getPk() == $name;
}
更新方法,不论是否真正有值被更新(没有自动时间戳的时候),都会触发模型事件
public function getOrigin(?string $name = null, bool $transfrom = false)
{
if ($name) {
$name = $this->getRealFieldName($name);
$result = $this->getWeakData('origin', $name);
return $transfrom ? $this->writeTransform($result, $this->getFields($name)) : $result;
}
return $this->getOption('origin');
}
$origin是空的,所以isNotRequireUpdate返回false
