Laravel: Laravel Eloquent关联关系

最后更新:2026-08-26

关联关系是 Eloquent 的"超能力"——一行代码就能穿越表与表之间的外键桥梁,告别手动 JOIN。

1. 你将学到


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 图

100%
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()。

📖 小节


📝 作业

  1. 基础题(⭐):为 ShopMetrics 定义 Tenant→Shop→Product 的关联关系,使用 tinker 创建测试数据并访问关联:$tenant->shops->first()->products

  2. 进阶题(⭐⭐):实现 Product 和 Category 的多对多关联,创建 pivot 迁移含 is_primary 列,使用 sync() 同步分类,查询某商品的主分类。

  3. 挑战题(⭐⭐⭐):实现一个多态评论系统(Comment 模型 morphTo 商品和商店),在仪表盘中预加载所有评论及其 commentable 关联,确保只有 3 条 SQL(1 查评论 + 1 查商品 + 1 查商店)。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏