Laravel: Laravel Eloquent关联关系
最后更新:2026-08-26
关联关系是 Eloquent 的"超能力"——一行代码就能穿越表与表之间的外键桥梁,告别手动 JOIN。
1. 你将学到
- 一对一:hasOne/belongsTo
- 一对多:hasMany/belongsTo
- 多对多:belongsToMany(含 pivot 表)
- HasManyThrough 远层关联与多态关联
- 预加载与懒加载:N+1 问题与 with()/load() 优化
2. 一个数据分析团队的真实故事
(1) 痛点:50 条查询只为了显示 10 个订单
Charlie 在 ShopMetrics 仪表盘显示 10 个订单——1 条查订单,然后每个订单查 1 次用户、1 次商店、1 次商品,共 1 + 10×4 = 41 条 SQL。数据库负载飙升,页面加载从 200ms 变成 3s。Bob 加了更多关联(订单→商品→分类→标签),查询数变成 200+,Alice 投诉系统"像蜗牛一样"。
(2) 预加载的解法
Eloquent 的 with() 预加载一次性取出所有关联数据,41 条 SQL 变成 4 条。
PHP
// N+1 problem — 41 queries
$orders = Order::take(10)->get();
foreach ($orders as $order) {
echo $order->user->name; // +1 query each
echo $order->shop->name; // +1 query each
}
// Eager loading — 4 queries total
$orders = Order::with(['user', 'shop', 'items.product'])->take(10)->get();
foreach ($orders as $order) {
echo $order->user->name; // 0 extra queries
echo $order->shop->name; // 0 extra queries
}
(3) 收益
Charlie 用预加载后,仪表盘查询从 41 条降到 4 条,页面加载从 3s 降到 300ms。
3. 一对一关联
(1) hasOne / belongsTo
PHP
// User has one Profile
class User extends Model
{
public function profile(): HasOne
{
return $this->hasOne(Profile::class);
}
}
// Profile belongs to User
class Profile extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
// Usage
$profile = $user->profile;
$user = $profile->user;
| 方向 | 方法 | 外键位置 | 说明 |
|---|---|---|---|
| User → Profile | hasOne | profiles 表 | 拥有 |
| Profile → User | belongsTo | profiles 表 | 属于 |
▶ 示例:ShopMetrics 用户与订阅计划
PHP
// User has one active subscription
class User extends Model
{
public function activeSubscription(): HasOne
{
return $this->hasOne(Subscription::class)
->where('status', 'active')
->latestOfMany();
}
}
// Subscription belongs to User
class Subscription extends Model
{
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
// Usage
$plan = $user->activeSubscription->plan;
输出:
TEXT
📖 仅展示
// 执行成功
4. 一对多关联
(1) hasMany / belongsTo
PHP
// Tenant has many Shops
class Tenant extends Model
{
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
}
// Shop belongs to Tenant
class Shop extends Model
{
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class);
}
public function products(): HasMany
{
return $this->hasMany(Product::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
}
(2) 七大关联关系 UML 图
classDiagram
class Tenant {
+shops() HasMany
+orders() HasMany
+users() HasMany
+subscription() HasOne
}
class User {
+tenant() BelongsTo
+profile() HasOne
+orders() HasMany
}
class Shop {
+tenant() BelongsTo
+products() HasMany
+orders() HasMany
}
class Product {
+shop() BelongsTo
+categories() BelongsToMany
}
class Category {
+products() BelongsToMany
}
class Order {
+shop() BelongsTo
+user() BelongsTo
+items() HasMany
}
class OrderItem {
+order() BelongsTo
+product() BelongsTo
}
Tenant "1" --> "*" Shop : hasMany
Tenant "1" --> "*" User : hasMany
Tenant "1" --> "1" Subscription : hasOne
Shop "1" --> "*" Product : hasMany
Shop "1" --> "*" Order : hasMany
Product "*" --> "*" Category : belongsToMany
Order "1" --> "*" OrderItem : hasMany
▶ 示例:ShopMetrics 租户的商店与订单
PHP
// Get tenant with all shops and their recent orders
$tenant = Tenant::with(['shops' => function ($query) {
$query->withCount(['orders' => function ($q) {
$q->where('created_at', '>=', now()->subDays(30));
}])->orderBy('revenue', 'desc');
}])->findOrFail($tenantId);
foreach ($tenant->shops as $shop) {
echo "{$shop->name}: {$shop->orders_count} recent orders";
}
输出:
TEXT
📖 仅展示
// 执行成功
5. 多对多关联
(1) belongsToMany 与 Pivot 表
PHP
// Product belongs to many Categories (via category_product pivot)
class Product extends Model
{
public function categories(): BelongsToMany
{
return $this->belongsToMany(Category::class)
->withPivot('is_primary')
->withTimestamps();
}
}
class Category extends Model
{
public function products(): BelongsToMany
{
return $this->belongsToMany(Product::class)
->withPivot('is_primary')
->withTimestamps();
}
}
(2) Pivot 表结构
TEXT
📖 仅展示
category_product
├── id
├── category_id (FK)
├── product_id (FK)
├── is_primary (BOOLEAN)
├── created_at
└── updated_at
| Pivot 方法 | 作用 |
|---|---|
withPivot() |
额外读取 pivot 列 |
withTimestamps() |
维护 pivot 的时间戳 |
as('alias') |
给 pivot 起别名 |
wherePivot() |
过滤 pivot 条件 |
sync() |
同步关联(增删差集) |
attach() |
添加关联 |
detach() |
移除关联 |
▶ 示例:ShopMetrics 商品分类多对多
PHP
// Attach categories to a product
$product->categories()->attach([1, 2, 3], ['is_primary' => false]);
$product->categories()->attach(4, ['is_primary' => true]);
// Sync — set exact categories (removes others)
$product->categories()->sync([
1 => ['is_primary' => false],
4 => ['is_primary' => true],
]);
// Sync without detaching — add only, don't remove
$product->categories()->syncWithoutDetaching([5, 6]);
// Query with pivot condition
$primaryCategory = $product->categories()
->wherePivot('is_primary', true)
->first();
// Detach specific categories
$product->categories()->detach([1, 2]);
输出:
TEXT
📖 仅展示
// 执行成功
6. 远层关联与多态关联
(1) HasManyThrough
PHP
// Tenant has many Products through Shop
class Tenant extends Model
{
public function products(): HasManyThrough
{
return $this->hasManyThrough(
Product::class, // final target
Shop::class, // intermediate
'tenant_id', // FK on shops
'shop_id', // FK on products
'id', // PK on tenants
'id', // PK on shops
);
}
}
// Usage: direct access without loading shops
$products = $tenant->products()->where('is_active', true)->get();
(2) 多态关联
PHP
// Image can belong to Shop or Product (morphable)
class Image extends Model
{
public function imageable(): MorphTo
{
return $this->morphTo();
}
}
class Shop extends Model
{
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}
class Product extends Model
{
public function images(): MorphMany
{
return $this->morphMany(Image::class, 'imageable');
}
}
// Migration for polymorphic
Schema::create('images', function (Blueprint $table) {
$table->id();
$table->morphs('imageable'); // imageable_type + imageable_id
$table->string('path');
$table->timestamps();
});
| 关联类型 | 方法 | 适用场景 |
|---|---|---|
| 一对一 | hasOne/belongsTo | 用户→个人资料 |
| 一对多 | hasMany/belongsTo | 租户→商店 |
| 多对多 | belongsToMany | 商品↔分类 |
| 远层一对多 | hasManyThrough | 租户→商品(通过商店) |
| 多态一对一 | morphOne/morphTo | 图片→商品/商店 |
| 多态一对多 | morphMany/morphTo | 评论→商品/文章 |
| 多态多对多 | morphToMany/morphByMany | 标签→商品/文章 |
▶ 示例:ShopMetrics 多态图片系统
PHP
// Add image to shop
$shop->images()->create(['path' => 'shops/alice-store/banner.jpg']);
// Add image to product
$product->images()->create(['path' => 'products/widget-a/thumb.jpg']);
// Query polymorphic — get image's owner
$image = Image::find(1);
$image->imageable; // Returns Shop or Product instance
// Eager load polymorphic
$images = Image::with('imageable')->get();
foreach ($images as $image) {
echo $image->imageable->name; // Works for both Shop and Product
}
输出:
TEXT
📖 仅展示
// 执行成功
7. 预加载与 N+1 优化
(1) N+1 问题
TEXT
📖 仅展示
Without eager loading:
1. SELECT * FROM orders WHERE tenant_id = 1 LIMIT 10 -- 1 query
2. SELECT * FROM users WHERE id = 1 -- +1 per order
3. SELECT * FROM users WHERE id = 2
4. SELECT * FROM shops WHERE id = 5
... (up to 30+ queries for 10 orders)
(2) with() 预加载
PHP
// Eager load — 4 queries total
$orders = Order::with(['user', 'shop', 'items.product'])->paginate(15);
// Nested eager loading
$orders = Order::with(['items.product.categories'])->get();
// Conditional eager loading
$orders = Order::with(['items' => function ($query) {
$query->where('quantity', '>', 1);
}])->get();
// Lazy eager loading — load after initial query
$orders = Order::all();
if ($needItems) {
$orders->load('items.product');
}
| 方法 | 时机 | 适用场景 |
|---|---|---|
with() |
查询时加载 | 已知需要关联 |
load() |
查询后加载 | 条件性加载 |
loadCount() |
只加载计数 | 只需数量不需数据 |
loadMissing() |
缺失时加载 | 避免重复加载 |
▶ 示例:ShopMetrics 仪表盘 N+1 优化
PHP
// BAD — N+1 problem in dashboard
$shops = Shop::where('tenant_id', $tenantId)->get();
foreach ($shops as $shop) {
echo $shop->orders->count(); // +1 query per shop
echo $shop->products->count(); // +1 query per shop
}
// GOOD — withCount + eager loading
$shops = Shop::where('tenant_id', $tenantId)
->withCount(['orders', 'products', 'orders as recent_orders_count' => function ($q) {
$q->where('created_at', '>=', now()->subDays(30));
}])
->with(['latestOrder'])
->get();
foreach ($shops as $shop) {
echo $shop->orders_count; // 0 extra queries
echo $shop->recent_orders_count; // 0 extra queries
echo $shop->latestOrder->total; // 0 extra queries
}
输出:
TEXT
📖 仅展示
// 执行成功
8. 综合示例:ShopMetrics 租户数据聚合
PHP
// ============================================
// Comprehensive: ShopMetrics Tenant Data Aggregation
// Covers: all relationship types, eager loading, pivot, polymorphic
// ============================================
// app/Models/Tenant.php
class Tenant extends Model
{
public function users(): HasMany
{
return $this->hasMany(User::class);
}
public function shops(): HasMany
{
return $this->hasMany(Shop::class);
}
public function orders(): HasMany
{
return $this->hasMany(Order::class);
}
public function subscription(): HasOne
{
return $this->hasOne(Subscription::class)->where('status', 'active');
}
public function products(): HasManyThrough
{
return $this->hasManyThrough(Product::class, Shop::class);
}
public function scopeWithStats(Builder $query): Builder
{
return $query->withCount([
'shops as active_shops_count' => fn ($q) => $q->where('status', 'active'),
'orders as monthly_orders_count' => fn ($q) => $q->whereBetween('created_at', [
now()->startOfMonth(), now()->endOfMonth(),
]),
])->withSum('orders as total_revenue', 'total');
}
}
// Usage — one query with everything
$tenant = Tenant::withStats()
->with(['subscription.plan', 'shops' => fn ($q) => $q->orderBy('revenue', 'desc')->take(5)])
->findOrFail($tenantId);
$tenant->active_shops_count; // 15
$tenant->monthly_orders_count; // 234
$tenant->total_revenue; // 45678.90
$tenant->subscription->plan->name; // Pro
$tenant->shops->first()->name; // Alice Store
❓ 常见问题
Q 如何判断用哪种关联?
A 看外键位置——外键在对方表用 hasOne/hasMany,外键在本表用 belongsTo,两表都没外键用 belongsToMany(需 pivot 表)。一个模型可属于多种类型用多态关联。
Q with() 和 load() 什么时候用?
A with() 在查询时一起加载,性能最优;load() 在查询后按需加载,适合条件性加载(如只在某条件下才需要关联数据)。
Q sync 和 attach 有什么区别?
A attach 只添加关联(不清除旧的),sync 设置精确的关联列表(多出的会被 detach)。需要"全量替换"用 sync,"增量添加"用 attach。
Q 多态关联会影响查询性能吗?
A 多态关联使用 imageable_type 列区分类型,无法建立传统外键约束。索引加上 (imageable_type, imageable_id) 可以提升性能。大量数据时考虑单独表替代多态。
Q 如何检测 N+1 问题?
A 安装 Laravel Debugbar,查看每个页面的 SQL 查询数。超过 20 条通常有 N+1 问题。也可用
DB::listen() 记录查询数或使用 laravel/telescope 监控。Q 关联方法不加()和加()有什么区别?
A
$shop->products 是动态属性(返回 Collection),$shop->products() 是关联查询构造器(可继续链式查询)。前者自动执行查询,后者延迟到手动调用 get()。📖 小节
- 一对一用 hasOne/belongsTo,外键在 belongsTo 那边
- 一对多用 hasMany/belongsTo,是最常见的关联类型
- 多对多用 belongsToMany,需要 pivot 中间表
- HasManyThrough 穿越中间表访问远层关联
- 多态关联让一个模型属于多种类型(如图片→商品/商店)
- 预加载 with() 解决 N+1 问题,withCount() 只计数量不加载数据
📝 作业
-
基础题(⭐):为 ShopMetrics 定义 Tenant→Shop→Product 的关联关系,使用 tinker 创建测试数据并访问关联:
$tenant->shops->first()->products。 -
进阶题(⭐⭐):实现 Product 和 Category 的多对多关联,创建 pivot 迁移含 is_primary 列,使用 sync() 同步分类,查询某商品的主分类。
-
挑战题(⭐⭐⭐):实现一个多态评论系统(Comment 模型 morphTo 商品和商店),在仪表盘中预加载所有评论及其 commentable 关联,确保只有 3 条 SQL(1 查评论 + 1 查商品 + 1 查商店)。