本节摘要:关系声明是 Eloquent 的承重结构:belongsTo、hasMany、belongsToMany、morphTo 四种关系覆盖了绝大多数表间协同。本节用工单系统的真实表结构把四种关系全部声明一遍,讲清外键方向、中间表字段、多态关联的场景,以及关系名与查询方法的衔接。读完你能看着表结构图,直接把关系方法写出来。
单表操作上一节已经顺了,真实业务的数据从来不住在一张表里:工单属于队组、单子派给多个工人、附件能挂在工单也能挂在日志上。这些协同若靠到处手写 join,业务代码会迅速变成 SQL 拼图。关系声明把这些协同固化成模型方法:$order->team 一调即得,join 的细节收进框架。
回看 5.1 节的地基:teams、work_orders(带 team_id 外键)、workers、work_order_worker 中间表。四种关系正好各就各位:
<?php // 一对多(反向):工单属于队组 —— 外键 team_id 在 work_orders 表上 public function team(): BelongsTo { return $this->belongsTo(Team::class); } // 多对多:工单与工人 —— 中间表 work_order_worker 撮合 public function workers(): BelongsToMany { return $this->belongsToMany(Worker::class, 'work_order_worker') ->withPivot('role') // 中间表的自有字段要显式带出 ->withTimestamps(); }
<?php // 队组侧:一对多(正向)—— 一个队组有多个工单 public function orders(): HasMany { return $this->hasMany(WorkOrder::class); } // 多态一对多:附件可以挂在任何业务对象上 // 附件表 attachments 里有 attachable_type 与 attachable_id 两列 public function attachable(): MorphTo { return $this->morphTo(); }
方向判断的口诀只有一句:外键在谁的表里,谁就是"属于"的那一方。工单表里有 team_id,所以工单 belongsTo 队组、队组 hasMany 工单;中间表两头各有外键,所以是 belongsToMany,两边都要声明。多态关联则是把"属于谁"这件事拆成两列:类型列存模型类名、编号列存主键值,一张附件表服务所有业务对象。
<?php // 读:动态属性拿关联,拿不到或没加载要小心(下一节讲预加载) $team = $order->team; $crew = $order->workers; // 集合 $role = $order->workers->first()?->pivot->role; // 中间表字段从 pivot 取 // 写:多对多的挂载与摘除 $order->workers()->attach($workerId, ['role' => '电工']); // 挂上新工人并注明工种 $order->workers()->sync([1 => ['role' => '工头'], 2 => ['role' => '电工']]); // 全量同步 $order->workers()->detach($workerId); // 摘除 // 一对多的写:从父方造子方,外键自动带上 $team->orders()->create(['title' => '新单子', 'budget' => 50000]); // 存在才建、不存在就更新: BELONGSTOMANY 的常用变体 $order->workers()->syncWithoutDetaching([$workerId => ['role' => '水电']]);
关系方法另一个隐藏用途是当查询入口用:$order->workers() 带括号得到的是关系查询器,可以继续链式加条件——wherePivot、whereHas 都是这么接上的:
<?php // 有电工在场的单子:跨关系做存在性筛选 $orders = WorkOrder::query() ->whereHas('workers', fn ($q) => $q->where('role', '电工')) ->get(); // 只查本队组的三张单:关系名即查询入口 $recent = $team->orders()->latest()->limit(3)->get();

中间表不只是撮合,还承载关系自身的属性(工种、派工时间),所以 withPivot 必须显式声明要带出的字段;要在中间表上加时间戳就用 withTimestamps。更重的中间表(比如派工要审批流)建议直接为它建独立模型,走一对多的两层关系,别硬塞进 belongsToMany。
⚠️ 常见坑清单:动态属性($order->workers)在不预加载时每次访问都触发查询,循环里这么写就是 N+1 现场,5.4 节的预加载是解药;sync 是全量覆盖语义,只想增量追加用 attach 或 syncWithoutDetaching,用错会静默删光既有关联;多态关联的类型列存的是类名,模型改命名空间会让旧数据失配,迁移时必须连数据一起改。
关系方法名可以随便起吗?
方法名是项目内的"通用语",建议与业务语义对齐而不是机械复述表关系:Order::crew() 比 workers() 更贴合工地语境,且这个名字会出现在 whereHas、with 的每一处调用里。改名的成本随项目增长线性上升,起名时多想一分钟。
关联数据太多怎么办(一个单子上百个工人)?
关系查询器支持继续链式约束:$order->workers()->limit(10)->get() 按需取,或预加载时加条件只装最近的。把上百条关联整体加载进内存再在 PHP 里截取,是最常见的内存与性能双重浪费。
关系声明完了,但协同的隐患(N+1)和写库的原子性还没处理。下一节收尾数据层:预加载、访问器、事务与批量浇筑。