Laravel deleting 事件递归删除分类树时,子节点标题为何异常?
结论
deleting 事件不会改变 children 关联的含义。正常情况下,它仍按当前模型的主键和 parent_id 查找直接子节点。
从这段代码可以确定,deleteWithChildren() 已经负责递归,而其中的 $category->delete() 又会触发包含递归逻辑的 deleting 回调。两套递归叠加会重复遍历,还可能访问关联集合中已经删除的模型对象。
这些代码不足以证明“子节点被替换成了兄弟节点”。标题异常的原因还需要结合节点 ID、parent_id、parent() 定义和实际查询结果判断。建议先用结构化日志确认节点身份,再只保留一个递归入口。
为什么移到事件后,调用顺序会变化?
Laravel 10.48.22 的实例 delete() 会先触发当前模型的 deleting 事件,再执行删除,最后触发 deleted。事件监听器可以返回 false 取消删除。具体顺序可见 Laravel 10.48.22 Model 源码。
原来的辅助方法采用后序遍历:先处理后代,再删除当前节点。加入事件中的递归后,调用过程会变成:
某节点的 deleting 回调
→ deleteWithChildren(子节点)
→ 递归处理后代
→ 子节点 delete()
→ 子节点的 deleting 回调
→ 再次遍历子节点的 children
另外,读取 $category->children 时,如果该实例已经加载过这个关联,就会复用已有的集合。数据库中的子节点被删除后,集合不会自动移除相应的 PHP 对象;$category->children()->get() 则会重新执行关联查询。这个缓存行为来自 Laravel 的关联属性读取实现。
因此,递归过程中的日志可能来自不同层级,也可能引用之前加载的对象。仅凭完整标题,容易误判当前处理的是哪个节点。原日志中的 deleted title 也应改为 deleting title,因为此时当前节点尚未删除。
将递归统一放在 deleting 事件中
如果希望每次调用分类实例的 delete() 时都删除其后代,可以让事件只查询直接子节点,再逐个调用子节点的 delete()。子节点自己的事件会继续处理下一层。
删除旧的 deleteWithChildren() 调用,并移除 controller 中原有的子节点循环。以下方法放在 Category 模型中;原有的路径 accessor 可以保留:
public function children()
{
return $this->hasMany(self::class, 'parent_id')->orderBy('sort');
}
public function parent()
{
return $this->belongsTo(self::class, 'parent_id');
}
protected static function booted(): void
{
static::deleting(function (self $category): void {
\Illuminate\Support\Facades\Log::info('deleting category', [
'id' => $category->getKey(),
'parent_id' => $category->parent_id,
'title' => $category->title,
'fitle' => $category->fitle,
]);
foreach ($category->children()->get() as $child) {
if ($child->delete() !== true) {
throw new \RuntimeException('Child category deletion failed.');
}
}
});
}
这里通过 children()->get() 读取当前查询可见的直接子节点,避免复用之前加载的 children 集合。它不能修复错误的外键数据或关联定义。
controller 只删除目标节点,并在该模型使用的数据库连接上开启事务:
public function destroy(Category $category)
{
$category->getConnection()->transaction(function () use ($category) {
if ($category->delete() !== true) {
throw new \RuntimeException('Category deletion failed.');
}
});
return redirect(route('category.index'))
->with('status', __('Category deleted'));
}
在支持事务且相关写入使用同一连接的前提下,异常会使数据库修改回滚。显式检查删除结果,可以避免某个监听器返回 false 后仍提交部分删除。日志等数据库之外的操作不会随事务回滚。
也可以继续使用原来的辅助方法递归,但应从 deleting 回调中移除递归逻辑。两种方案选一种即可。
如果标题仍然不对,如何定位?
先区分“关联返回了错误节点”和“正确节点的完整路径算错了”。可以临时在回调中加入以下日志:
$cachedChildIds = $category->relationLoaded('children')
? $category->getRelation('children')->modelKeys()
: null;
$children = $category->children()->get();
\Illuminate\Support\Facades\Log::info('category children snapshot', [
'current_id' => $category->getKey(),
'current_parent_id' => $category->parent_id,
'cached_child_ids' => $cachedChildIds,
'queried_child_ids' => $children->modelKeys(),
]);
foreach ($children as $child) {
\Illuminate\Support\Facades\Log::info('category child identity', [
'current_id' => $category->getKey(),
'child_id' => $child->getKey(),
'child_parent_id' => $child->parent_id,
'raw_title' => $child->getAttributes()['title'] ?? null,
'title' => $child->title,
'fitle' => $child->fitle,
]);
}
按以下顺序检查:
- 确认父子关系。查询结果中每个子节点的
parent_id应指向当前节点的主键。如果不是,检查数据库记录、主键配置以及实际执行的关联查询。 - 比较缓存和查询结果。两组 ID 不一致时,检查之前是否加载了受约束的关联,或通过
setRelation()等方式设置过集合。差异本身不代表 Laravel 把子节点换成了兄弟节点。 - 检查标题和完整路径。如果节点 ID、
parent_id、title都正确,只有fitle错误,应重点排查parent()和路径中的祖先节点。原问题没有提供parent()定义,因此无法确认路径关联是否正确。 - 按 ID 阅读递归日志。每一层回调中的
$category都是该次删除的当前模型,不能默认相邻日志都是同一个父节点的输出。
getFitleAttribute() 对应的属性名确实是 fitle。这个命名容易看成拼写错误,但与现有调用一致,单凭它不能解释标题错位。
实现前需要确认的条件
上述事件方案适用于没有环的分类树,并按普通实例删除流程执行。自指向或祖先回指会让路径计算、递归删除无法正常结束,需要先检查树数据。
不要将逐个实例删除直接改成 $category->children()->delete():这类批量删除不会为每个子模型逐一触发模型事件,不能依靠这里的回调继续删除更深层后代。
如果模型使用 SoftDeletes,还需要明确软删除、forceDelete() 和已软删除后代的处理规则;默认查询的可见范围可能不足以覆盖全部后代。其他 global scopes 也可能隐藏节点,应按业务规则明确处理范围。
树很深或节点很多时,逐节点查询和删除会增加数据库访问次数;事务也不能单独保证遍历期间没有其他请求新增子节点。这些情况需要另行设计批处理或并发控制。
原示例还有一个独立问题:controller 调用了声明为 protected 的 deleteWithChildren()。按展示的可见性,controller 无法直接调用它。如果保留该方案,需要提供公开入口;采用上面的事件方案则无需这个辅助方法。
备注:内容仅供参考。
