メシのタネ

メシのタネになる、Laravelや設計思想の技術配信サイト


Laravelの多対多リレーションとpivotカラム運用の落とし穴


  1. Laravel
  2. Laravelの多対多リレーションとpivotカラム運用の落とし穴

🎯 この記事の目的

Laravelで多対多(Many to Many)リレーションを使う際、特に「中間テーブル(pivot)」に追加カラムを入れ始めた瞬間から設計と挙動が破綻し始める。この記事では、最低限これだけやっとけば後で爆発しないという構成とテストの例を通して、地雷を回避する方法を示す。

🧘 想定読者

  • Relation周りの定義に不安がある人
  • リレーションモデルを手軽に動かして検証したい人
  • そもそもpivotにカラム持たせてるけど、これ設計的に合ってんの?と思ってる人
  • syncとかsyncWithoutDetachingの挙動に不安がある人
  • テーブルの設計に不安がある人

🔨 SetUp

Eloquentのテストがしたいですが、それをする前に、面倒ですがファイルを作成しないといけませんので、シャシャッと作りましょう。これは虚無の修行🧘

たねまる

やってらんね〜って人のために、GithubにRepository用意したよ〜(修行Repository
この記事が良いと思った人は👍と高評価よろしくね〜〜!

🌏 コマンド

php artisan make:model Project -m
php artisan make:model Member -m
php artisan make:migration create_member_project_table

🏗 projects migration

Schema::create('projects', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->timestamps();
});

🧑‍💼 members migration

Schema::create('members', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->timestamps();
});

🔗 member_project pivot migration

Schema::create('member_project', function (Blueprint $table) {
    $table->foreignId('project_id')->constrained()->onDelete('cascade');
    $table->foreignId('member_id')->constrained()->onDelete('cascade');
    $table->string('assigned_by')->nullable();
    $table->timestamp('assigned_at')->nullable();
    $table->string('role')->nullable();
    $table->softDeletes();
    $table->timestamps();

    $table->primary(['project_id', 'member_id']);
});

Project.php


class Project extends Model
{
    protected $fillable = ['name'];

    public function members()
    {
        return $this->belongsToMany(Member::class)
            ->withPivot(['assigned_by', 'assigned_at', 'role', 'deleted_at'])
            ->withTimestamps()
            ->using(MemberProject::class);
    }
}

Member.php

class Member extends Model
{
    protected $fillable = ['name'];

    public function projects()
    {
        return $this->belongsToMany(Project::class)
            ->withPivot(['assigned_by', 'assigned_at', 'role', 'deleted_at'])
            ->withTimestamps()
            ->using(MemberProject::class);
    }
    
}

MemberProject.php

class MemberProject extends Pivot
{
    use SoftDeletes;
    protected $table = 'member_project';
    protected $dates = ['deleted_at'];
}

ここまででセットアップ完了!次からテストやっていきます。

🧪 テスト一覧

✅ attach() の基本検証

test('member can be assigned to a project with metadata', function () {
    // 中間テーブルに pivot 情報付きで attach できるかを検証
    
    // 準備
    $project = Project::create(['name' => 'Mercury']);
    $member = Member::create(['name' => 'Kohaku']);

    // 実行
    $project->members()->attach($member->id, [
        'assigned_by' => 'Boss',
        'assigned_at' => now(),
        'role' => 'Lead',
    ]);

    // DBのデータを直接検証することもできるよ!
    
    // 検証
    $this->assertDatabaseHas('member_project', [
        'project_id' => $project->id,
        'member_id' => $member->id,
        'assigned_by' => 'Boss',
        'role' => 'Lead',
    ]);
});
  • 中間テーブルにpivotカラムを持たせたとき、値が正しく保存されているかの最小構成テスト。

🔁 syncWithoutDetaching() での重複防止

test('syncWithoutDetaching does not duplicate member_project row', function () {
    // syncWithoutDetaching で中間テーブルに pivot 情報付きで attach できるかを検証 (1レコードのみ追加されることを検証)
    
    // 準備
    $project = Project::create(['name' => 'Venus']);
    $member = Member::create(['name' => 'Yuzu']);

    // 実行
    $project->members()->syncWithoutDetaching([
        $member->id => [
            'assigned_by' => 'Otwell',
            'assigned_at' => now(),
            'role' => 'Engineer',
        ]
    ]);

    $project->members()->syncWithoutDetaching([
        $member->id => [
            'assigned_by' => 'Otwell',
            'assigned_at' => now(),
            'role' => 'Engineer',
        ]
    ]);

    // 検証
    $this->assertDatabaseCount('member_project', 1);
});
  • 同じIDでsyncを何度呼んでもレコードが1件のままであることを確認。

🚫 withPivot を書かないと pivot は読めない

test('plain relation does not expose pivot metadata', function () {
    // withPivot を定義していないリレーションでは pivot 情報が null になることを確認
    
    // 準備
    $project = Project::create(['name' => 'Split Test']);
    $member = Member::create(['name' => 'Shion']);

    // 実行
    $project->plainMembers()->attach($member->id, [
        'role' => 'Intern',
        'assigned_by' => 'Admin',
        'assigned_at' => now(),
    ]);
    
    // 検証
    $fetchedMember = $project->plainMembers()->first();
    expect($fetchedMember->pivot->assigned_by)->toBeNull(); // assigned_by 登録してるのにNullになってるよ!?
    expect($fetchedMember->pivot->role)->toBeNull(); // role 登録してるのにNullになってるよ!?
});
  • withPivot を忘れると Eloquent はpivot情報を持っていても null を返す。

🧟‍♂️ ソフトデリートと detach()

test('member can be detached from project', function () {
    // given
    $project = Project::create(['name' => 'Mars']);
    $member = Member::create(['name' => 'Akira']);

    // when
    $project->members()->attach($member->id);
    $project->members()->detach($member->id);

    // ここでdeleted_atがnullのものを探すことの意味を問いましょう
    // then
    $exists = DB::table('member_project')->where([
        'project_id' => $project->id,
        'member_id' => $member->id,
    ])->whereNull('deleted_at')->exists();
    expect($exists)->toBeFalse();
});
  • 一応できるのでわざわざ中間テーブを論理削除にしてます。
  • ただし、本当にそうする必要があるかどうかは相談した方が良いです。
たねまる

deleted_at常に気にするのは大変なんだよね〜

🔥 sync() は無慈悲に他のpivotを物理削除する

test('sync replaces all pivot rows with the given set', function () {
    // sync() の挙動として、指定されていないpivot行が削除されるかを検証
    // 3人のメンバーを持つプロジェクトを作成  
    $project = Project::create(['name' => 'Destruction Test']);

    $member1 = Member::create(['name' => 'Alpha']);
    $member2 = Member::create(['name' => 'Bravo']);
    $member3 = Member::create(['name' => 'Charlie']);

    // 初期状態:3人を追加
    $project->members()->sync([
        $member1->id => ['role' => 'Engineer'],
        $member2->id => ['role' => 'Manager'],
        $member3->id => ['role' => 'Lead'],
    ]);

    expect(DB::table('member_project')->count())->toBe(3);

    // syncで1人だけにしたら、他の2人が全削除される
    $project->members()->sync([
        $member2->id => ['role' => 'Manager'], // Bravoだけ残す
    ]);

    // 論理削除だと、Prodコードでもこれやらないといけないのでまぁまぁだるい(運用コストと相談すること)
    $alive = $project->members->filter(fn ($m) => $m->pivot->deleted_at === null);

    expect($alive->count())->toBe(1)
        ->and($alive->first()->pivot->role)->toBe('Manager')
        ->and($alive->first()->id)->toBe($member2->id);
});
  • 現在の状態をリクエストに応じて同期させるっていう表現が分かりやすいかも。これはあるあるなんじゃないでしょうか。割と事故率高い。

😇 syncWithoutDetaching() は削除しないが“更新”はする

test('syncWithoutDetaching replaces all pivot rows with the given set', function () {
    // syncWithoutDetaching() の挙動として、指定されていないpivot行が削除されないかを検証
    // given
    $project = Project::create(['name' => 'Destruction Test']);

    $member1 = Member::create(['name' => 'Alpha']);
    $member2 = Member::create(['name' => 'Bravo']);
    $member3 = Member::create(['name' => 'Charlie']);

    $project->members()->sync([
        $member1->id => ['role' => 'Engineer'],
        $member2->id => ['role' => 'Manager'],
        $member3->id => ['role' => 'Lead'],
    ]);

    expect(DB::table('member_project')->count())->toBe(3);

    // when
    $project->members()->syncWithoutDetaching([
        $member2->id => ['role' => null],
    ]);
    
    // then
    // 変化なしだけど、論理削除のため、有効レコード(NULLかどうか)を確認する必要がある
    $alive = $project->members->filter(fn ($m) => $m->pivot->deleted_at === null);
    expect($alive->count())->toBe(3)
        ->and($alive->get(1)->pivot->role)->toBe(null)
        ->and($alive->get(1)->id)->toBe($member2->id)
        ->and($alive->get(1)->name)->toBe('Bravo');
});
  • 指定されたレコードは上書きされる。他はそのまま残る。

🔗 論理削除されたレコードのIDがユニーク制約と衝突する地獄

中間テーブルや関連テーブルに SoftDeletes を入れた状態で、片方のカラムに unique 制約があるとこうなる:

  1. member_id = 5 のレコードが論理削除される(deleted_at が入る)
  2. 再度 member_id = 5 を使って insert しようとすると、DB側の unique 制約に引っかかる

これは「論理削除された行も一応存在してる」ため、ユニーク制約上はまだそこにいると見なされるから。

✅ 対策:

かつ「削除済みをどう扱うか」をアプリ側でロジック化しておくこと(restore か、別エラーとして通知)

サロゲートキー(id)を主キーにしない設計の場合は、確実に複合ユニークキーにすること

本記事のコードはGithubにPushしてますので、興味のある方はどうぞ


コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

This site uses Akismet to reduce spam. Learn how your comment data is processed.