Laravel递归树形删除中deleting事件子节点标题打印异常问题
Laravel自引用模型递归删除异常问题
问题背景
我有一个用于分类树形结构的Laravel自引用模型,每个节点可包含子节点,需要删除节点时连带删除所有后代。
模型关联定义
public function children() { return $this->hasMany(self::class, 'parent_id')->orderBy('sort'); }
树形路径访问器与递归方法
定义访问器用于构建完整树形路径标题:
// Full title public function getFitleAttribute() { return implode(' > ', array_map(function ($category) { return $category->title; }, $this->getTreePath())); }
递归路径方法:
public function getTreePath($model = null) { $path = []; if ($model === null) { $model = $this; } $path = $this->getTreePathNode($model); return array_reverse($path); } public function getTreePathNode($model) { $path[] = $model; if ($model->parent) { $path = array_merge($path, $this->getTreePathNode($model->parent)); } return $path; }
控制器中删除逻辑(正常工作)
在控制器执行删除时,日志能正确打印子节点标题:
public function destroy(Category $category) { foreach ($category->children as $child) { Log::info('deleting child title: ' . $child->fitle); // 打印正确的子节点标题 Category::deleteWithChildren($child); } $category->delete(); return redirect(route('category.index')) ->with('status', __('Category deleted')); }
递归删除方法:
protected static function deleteWithChildren($category) { foreach ($category->children as $child) { self::deleteWithChildren($child); } $category->delete(); }
移至模型deleting事件后出现异常
将递归删除逻辑移至模型deleting事件后,日志打印错误的子节点标题(显示兄弟节点而非实际子节点):
public static function booted(): void { static::deleting(function ($category) { Log::info('deleted title: ' . $category->fitle); foreach ($category->children as $child) { Log::info('deleting child title: ' . $child->fitle); static::deleteWithChildren($child); } }); }
疑问
原本预期deleting事件中的$category->children始终包含当前模型的直接子节点,请问为何表现不同?Laravel中递归删除自引用树形结构的正确方式是什么,才能避免获取错误的子节点数据?
环境:Laravel Framework 10.48.22
问题原因
核心问题出在Eloquent模型的关联缓存和事件执行顺序:
- 在
deleting事件中调用static::deleteWithChildren($child)时,删除子节点会触发其自身的deleting事件,但父模型的children关联已加载到内存(缓存)中。子节点被删除后,Eloquent不会自动更新父模型的关联缓存,导致后续遍历父模型children时,拿到的还是初始加载的集合(包含已删除节点),最终引发日志打印错误。 getTreePath方法依赖$model->parent关联,父节点处于deleting事件阶段时,模型状态可能干扰关联加载逻辑,导致路径计算错误。
正确的递归删除方式
方案1:禁用关联缓存,强制从数据库拉取子节点
在deleting事件中,通过关联查询直接从数据库获取最新子节点,绕过缓存:
public static function booted(): void { static::deleting(function ($category) { // 强制从数据库获取当前节点的直接子节点 $children = $category->children()->get(); foreach ($children as $child) { Log::info('deleting child title: ' . $child->fitle); $child->delete(); // 触发子节点的deleting事件,自动递归删除 } }); }
此时可删除原有的deleteWithChildren静态方法,事件会自动递归触发。
方案2:数据库级联删除(推荐)
如果数据库支持外键约束(如MySQL InnoDB),直接在迁移中设置onDelete('cascade'),让数据库自动处理递归删除,这是最高效且避免缓存问题的方式:
// 分类表迁移文件 public function up() { Schema::create('categories', function (Blueprint $table) { $table->id(); $table->string('title'); $table->integer('parent_id')->nullable(); $table->integer('sort')->default(0); $table->timestamps(); // 添加外键约束,级联删除子节点 $table->foreign('parent_id') ->references('id') ->on('categories') ->onDelete('cascade'); }); }
这种方式下,只需调用$category->delete(),数据库会自动删除所有后代节点,无需手动处理递归。
方案3:清理关联缓存后重新加载
若必须使用Eloquent事件处理,可在遍历前清理模型的关联缓存:
public static function booted(): void { static::deleting(function ($category) { // 清理children关联的缓存 $category->unsetRelation('children'); $children = $category->children; foreach ($children as $child) { Log::info('deleting child title: ' . $child->fitle); $child->delete(); } }); }
额外优化建议
- 可优化
getTreePath方法,比如使用Laravel嵌套集扩展(如kalnoy/nestedset)的ancestors关联,或缓存路径结果避免重复递归查询。 - 若用事件处理删除,建议先一次性获取所有子节点集合,避免遍历过程中缓存导致的集合变化。
内容的提问来源于stack exchange,提问作者bcExpt1123
相关产品推荐
相关产品推荐

