Laravel 11 模型关联:多对多

多对多关联是数据库设计中相对复杂的关系,例如「用户和角色」:一个用户可以拥有多个角色,一个角色也可以被多个用户拥有。 Laravel 的 Eloquent ORM 通过 belongsToMany 方法提供了简洁优雅的多对多关联定义和操作方式。 本章将详细介绍如何定义和使用多对多关联。

🎯 什么是多对多关联?

多对多关联表示两个模型之间可以互相拥有多个对方。例如:

  • 用户(User)和角色(Role):一个用户可以有多个角色,一个角色也可以分配给多个用户。
  • 文章(Post)和标签(Tag):一篇文章可以有多个标签,一个标签也可以属于多篇文章。

在关系型数据库中,多对多关联需要一张「中间表」(也称为枢轴表),用于记录两个模型之间的关联关系。

示例表结构:
users表
- id
- name
- email

roles表
- id
- name
- slug

role_user表 (中间表,按字母顺序命名)
- user_id
- role_id
- created_at (可选)
- updated_at (可选)

📦 定义多对多关联

使用 belongsToMany 方法定义多对多关联,该方法返回 Illuminate\Database\Eloquent\Relations\BelongsToMany 实例。

1. 基本定义

在 User 模型中定义与 Role 的多对多关联:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    public function roles()
    {
        return $this->belongsToMany(Role::class);
    }
}

在 Role 模型中定义反向关联:

class Role extends Model
{
    public function users()
    {
        return $this->belongsToMany(User::class);
    }
}
2. 指定中间表

如果中间表名不是默认的(按字母顺序连接两个模型名),可以自定义:

return $this->belongsToMany(Role::class, 'user_role');
3. 自定义外键和关联键

如果中间表的外键或关联键不是默认的,可以分别指定:

return $this->belongsToMany(Role::class, 'user_role', 'user_id', 'role_id');
4. 添加中间表额外字段

如果中间表除了外键外还有其他字段(如创建时间、附加属性),可以用 withPivot 方法:

return $this->belongsToMany(Role::class)
            ->withPivot('created_at', 'expires_at');

如果中间表包含时间戳字段,可以用 withTimestamps

return $this->belongsToMany(Role::class)->withTimestamps();
💡 命名约定: Laravel 会基于模型类名按字母顺序连接生成中间表名。例如 UserRole 生成 role_user。如果你希望使用其他名称,请显式指定。

🔍 使用多对多关联

定义好关联后,可以方便地获取、添加、更新和删除关联数据。

1. 获取关联数据
$user = User::find(1);
$roles = $user->roles; // 集合

foreach ($roles as $role) {
    echo $role->name;
}
2. 添加关联(attach)

使用 attach 方法将模型关联起来:

// 添加单个角色
$user->roles()->attach($roleId);

// 添加多个角色
$user->roles()->attach([1, 2, 3]);

// 同时设置中间表字段
$user->roles()->attach($roleId, ['expires_at' => '2025-12-31']);
3. 移除关联(detach)
// 移除单个角色
$user->roles()->detach($roleId);

// 移除多个角色
$user->roles()->detach([1, 2, 3]);

// 移除所有角色
$user->roles()->detach();
4. 同步关联(sync)

sync 方法会同步关联数组,只保留数组中指定的 ID,其他将被移除:

$user->roles()->sync([1, 2, 3]); // 用户只拥有角色1,2,3

// 同时传递中间表字段
$user->roles()->sync([
    1 => ['expires_at' => '2025-01-01'],
    2 => ['expires_at' => '2025-06-01'],
]);

如果不想移除未指定的关联,可以使用 syncWithoutDetaching

$user->roles()->syncWithoutDetaching([1, 2]); // 添加角色1和2,但保留原有其他角色
5. 切换关联(toggle)

toggle 方法会添加不存在的关联,并移除已存在的关联:

$user->roles()->toggle([1, 2, 3]);
6. 更新中间表字段(updateExistingPivot)
$user->roles()->updateExistingPivot($roleId, ['expires_at' => '2026-01-01']);

📊 访问中间表(Pivot)数据

当访问关联时,中间表数据可以通过 pivot 属性访问:

$user = User::find(1);
foreach ($user->roles as $role) {
    echo $role->pivot->created_at; // 如果中间表有 created_at 字段
}

注意:只有通过 withPivot 声明的字段才能被访问。

你可以给中间表自定义一个名称(如 as('membership'))来替代 pivot

return $this->belongsToMany(Role::class)->as('membership')->withTimestamps();

然后通过 $role->membership->created_at 访问。

⚡ 关联查询与预加载

1. 过滤关联数据
$users = User::whereHas('roles', function ($query) {
    $query->where('name', 'admin');
})->get();
2. 预加载多对多关联
$users = User::with('roles')->get();
foreach ($users as $user) {
    foreach ($user->roles as $role) {
        echo $role->name;
    }
}

预加载时也可以过滤:

$users = User::with(['roles' => function ($query) {
    $query->where('active', true);
}])->get();
3. 嵌套预加载
// 用户 -> 角色 -> 权限(假设角色有多对多权限)
$users = User::with('roles.permissions')->get();

🎨 实战示例:用户角色系统

下面是一个完整的用户角色管理示例。

模型定义:

// User.php
public function roles()
{
    return $this->belongsToMany(Role::class)
                ->withPivot('expires_at')
                ->withTimestamps();
}

// Role.php
public function users()
{
    return $this->belongsToMany(User::class)
                ->withPivot('expires_at')
                ->withTimestamps();
}

使用示例:

// 给用户分配角色
$user->roles()->attach($roleId, ['expires_at' => now()->addYear()]);

// 获取用户所有角色,并查看过期时间
$user = User::with('roles')->find(1);
foreach ($user->roles as $role) {
    echo "角色: {$role->name}, 过期时间: {$role->pivot->expires_at}";
}

// 同步角色,并设置每个角色的过期时间
$user->roles()->sync([
    1 => ['expires_at' => '2025-01-01'],
    2 => ['expires_at' => '2025-06-01'],
]);

// 获取所有拥有 'admin' 角色的用户
$admins = User::whereHas('roles', function ($query) {
    $query->where('name', 'admin');
})->get();

// 获取某个角色的所有用户,并按用户名排序
$role = Role::where('name', 'editor')->first();
$users = $role->users()->orderBy('name')->get();
💡 最佳实践: 在控制器中使用预加载避免 N+1 查询。使用 sync 方法时注意性能,因为它会执行删除和插入操作。对于大量数据,考虑使用 attachdetach 分批处理。

🚀 高级技巧

1. 自定义中间表模型

如果中间表需要更多的业务逻辑或事件,可以创建一个中间表模型,并在关联中使用:

// 创建中间表模型 UserRole
use Illuminate\Database\Eloquent\Relations\Pivot;

class UserRole extends Pivot
{
    // 自定义逻辑
}

// 在关联中使用
return $this->belongsToMany(Role::class)->using(UserRole::class);
2. 定义中间表的访问器和修改器

如果使用自定义中间表模型,可以在其中定义访问器和修改器。

3. 通过关联关系删除中间表记录
$user->roles()->detach($roleId);
4. 获取中间表数据作为集合

使用 newPivotQuery 获取中间表的查询构建器:

$user->roles()->newPivotQuery()->where('expires_at', '<', now())->delete();

⚠️ 常见问题

Laravel 会取两个模型类名的蛇形复数形式,按字母顺序连接,并用下划线分隔。例如 UserRole 生成 role_userPostTag 生成 post_tag

在定义关联时使用 withPivot('field1', 'field2') 声明额外字段,然后在 attachsync 等操作时传递这些字段的值。

sync 会移除所有不在给定数组中的关联,只保留数组中的。而 syncWithoutDetaching 仅添加新的关联,不会移除已有的。

📝 小结

多对多关联是 Eloquent 中非常强大的特性,通过 belongsToMany 方法可以轻松定义,并利用 attachdetachsync 等方法管理关联。 掌握中间表操作和预加载技巧,可以让复杂的关系处理变得简单高效。 下一章我们将学习模型的其他高级特性,如访问器、修改器和事件。