# 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