Laravel『Pivot Table Issues(中間テーブルの不具合)』の原因と対処法

Laravel『Pivot Table Issues(中間テーブルの不具合)』の原因と対処法

Laravelの「Pivot Table Issues」は、belongsToMany の中間テーブル(pivot)が絡む操作で、想定した関連が取得できない/attach・detach・sync が失敗する/pivotカラムが取れない/重複が増える/外部キー制約で落ちる、といった問題の総称として扱われることが多い。原因は大きく「中間テーブル名・外部キー名の不一致」「主キー/ユニーク制約が無く重複が発生」「pivotカラム指定(withPivot)が不足」「timestamps運用(withTimestamps)不整合」「sync/attachの使い方(配列形式)が誤り」「ソフトデリート・追加条件(wherePivot)との相性」「同名カラム衝突(idなど)」「DB設計(外部キー制約/ON DELETE)とアプリ操作の齟齬」に分かれる。最初に「Laravelが想定するテーブル名とキー名」と「実DBの定義」が一致しているかを確定させる。

症状と発生条件(典型例)

よくある症状:
・$user->roles が空になる/一部しか取れない
・attach/sync したのに反映されない、または重複して増える
・pivotカラムにアクセスすると undefined / null
・SQLエラー:column not found / ambiguous column / foreign key violation
・sync() 実行で意図しない detach が起きる
・withTimestamps しているのに created_at/updated_at がないと言われる
よくあるメッセージ例。

SQLSTATE[42P01]: Undefined table: relation "role_user" does not exist
SQLSTATE[42703]: Undefined column: column "user_id" does not exist
SQLSTATE[23503]: Foreign key violation
Undefined property: Illuminate\Database\Eloquent\Relations\Pivot::$xxx
Column 'id' in field list is ambiguous

まず確認:中間テーブル名と外部キー名(Laravelのデフォルト規約)

belongsToMany は規約だと以下を期待する。
・中間テーブル名:2つのモデル名の単数スネークケースをアルファベット順で結合(例:role_user)
・外部キー:user_id / role_id
実DBが違う場合、belongsToMany の第2〜第4引数で明示する必要がある。

// User.php
public function roles()
{
    return $this->belongsToMany(Role::class); // 規約通りならこれでOK
}

テーブル名が違う例(user_roles)。

public function roles()
{
    return $this->belongsToMany(Role::class, 'user_roles', 'user_id', 'role_id');
}

外部キー名が違う例(account_id / permission_id など)。

public function permissions()
{
    return $this->belongsToMany(Permission::class, 'account_permissions', 'account_id', 'permission_id');
}

原因1:中間テーブルのマイグレーションが想定と違う(命名・カラム不足)

発生条件:
・テーブル名が規約と違うのに belongsToMany() で指定していない
・外部キーが userId / roleId のようにスネークケースでない
・pivotに created_at/updated_at が無いのに withTimestamps() している
対処:
・belongsToMany の引数で明示するか、DBを規約に寄せる
・timestamps運用するなら中間テーブルにも timestamps を追加する

// pivot migration例
Schema::create('role_user', function (Blueprint $table) {
    $table->foreignId('user_id')->constrained()->cascadeOnDelete();
    $table->foreignId('role_id')->constrained()->cascadeOnDelete();
    $table->timestamps(); // withTimestampsを使うなら必要
    $table->unique(['user_id', 'role_id']); // 重複防止
});

原因2:重複が増える(ユニーク制約なし/attachの使い方)

発生条件:
・attach を同じ組み合わせで複数回呼ぶ
・中間テーブルに (user_id, role_id) のユニーク制約がない
対処:
・ユニーク制約を付ける
・重複させたくない場合は syncWithoutDetaching を使う

// 既存は残しつつ、追加だけ行う(重複しない)
$user->roles()->syncWithoutDetaching([$roleId]);

ユニーク制約があると、二重attach時にDBが弾いてくれて事故が減る。

原因3:pivotカラムが取れない(withPivot/withTimestamps不足)

発生条件:
・pivotに status, sort_order など追加カラムがあるのに withPivot していない
・pivotのtimestampsを使いたいのに withTimestamps() していない
対処:関連定義で明示する。

public function roles()
{
    return $this->belongsToMany(Role::class)
        ->withPivot(['status', 'sort_order'])
        ->withTimestamps();
}

アクセス例。

foreach ($user->roles as $role) {
    echo $role->pivot->status;
}

原因4:attach/sync の配列形式が誤り(pivot値の渡し方)

発生条件:
・pivot値を渡したいのに配列構造が違う
・sync の引数が [id => [pivot…]] になっていない
正しい例(attach)。

$user->roles()->attach($roleId, ['status' => 'active']);

正しい例(sync:置き換え)。

$user->roles()->sync([
    $roleId1 => ['status' => 'active'],
    $roleId2 => ['status' => 'inactive'],
]);

sync は指定されていない関連を detach する(これが「勝手に外れた」に見える原因になりやすい)。

原因5:sync が意図せず detach する(仕様の取り違え)

発生条件:
・追加だけのつもりで sync を使った
・フォーム送信時に一部IDが欠けていて全体が置き換わる
対処:追加だけなら syncWithoutDetaching、削除も含むなら sync を使う。

// 追加だけ
$user->roles()->syncWithoutDetaching($roleIds);

// 全置き換え(送信値が完全である前提)
$user->roles()->sync($roleIds);

部分更新したいなら、現在の関連を取得して差分計算する運用が安全。

原因6:pivotテーブルに主キーが無い/扱いが難しい(カスタムPivot/中間モデル化)

発生条件:
・pivotに独自の id(主キー)を持たせていて、更新・削除の粒度が複雑
・pivotを単なる中間ではなく“関連エンティティ”として扱いたい(有効期限、作成者など)
対処:中間テーブルを「中間モデル(Pivotモデル)」として扱う、または通常のモデル+hasManyで設計し直す。
Pivotモデルを使う例(概念)。

use Illuminate\Database\Eloquent\Relations\Pivot;

class RoleUser extends Pivot
{
    protected $table = 'role_user';
    protected $fillable = ['status', 'sort_order'];
}

関連側で using(…) を指定する。

public function roles()
{
    return $this->belongsToMany(Role::class)
        ->using(RoleUser::class)
        ->withPivot(['status', 'sort_order'])
        ->withTimestamps();
}

「関連そのものに意味がある」場合、belongsToMany で無理に運ぶより、中間を通常モデル化した方が運用が安定する。

原因7:wherePivot / join でカラム衝突(ambiguous column / 上書き)

発生条件:
・pivotと関連テーブルで同名カラム(id, created_at など)があり、join時に曖昧になる
・select * で取得して、どちらのカラムか分からなくなる
対処:select を明示し、テーブル名/エイリアスで修飾する。

$user->roles()
    ->wherePivot('status', 'active')
    ->select('roles.*') // 役割テーブルだけ明示
    ->get();

join を自前で書く場合は roles.id のように修飾する。

原因8:外部キー制約で削除できない(pivotが残っている)

発生条件:
・role を削除しようとしたが role_user が残っていて FK violation
・cascade設定が無い(または想定通りに効いていない)
対処:
・先に detach する
・DB側で cascadeOnDelete を付ける(運用方針に合わせる)

// 削除前に中間を外す
$role->users()->detach();
$role->delete();

DB設計で cascade を採るなら、マイグレーションで明示する。

$table->foreignId('role_id')->constrained()->cascadeOnDelete();

サンプル:Users と Roles の多対多(pivotカラムあり)

最小構成の例。

// 追加(status付き)
$user->roles()->attach($roleId, ['status' => 'active']);

// 追加だけ(既存は維持)
$user->roles()->syncWithoutDetaching([
    $roleId => ['status' => 'active'],
]);

// 全置き換え
$user->roles()->sync([
    $roleId1 => ['status' => 'active'],
    $roleId2 => ['status' => 'inactive'],
]);

// 取り外し
$user->roles()->detach($roleId);

// 条件付き取得
$activeRoles = $user->roles()->wherePivot('status', 'active')->get();

チェックリスト(上から順に確認する)

1) pivotテーブル名は規約通りか?違うなら belongsToMany の第2引数で明示しているか
2) 外部キー名は user_id / role_id など規約通りか?違うなら第3・第4引数で明示しているか
3) 重複が出るなら (外部キー組) の unique 制約があるか、attach を多重に呼んでいないか
4) pivotカラムを読むなら withPivot を書いているか、timestampsなら withTimestamps + pivot側timestampsがあるか
5) sync を “置き換え” と理解しているか(追加だけなら syncWithoutDetaching)
6) キー制約違反なら detach/cascadeOnDelete の方針がDB設計と一致しているか
7) join/select で曖昧カラムが出るなら select(‘related.*’) などで明示しているか