受注明細や在庫履歴では、1つの id ではなく、複数の列で1行を特定するテーブルがあります。 たとえば、受注明細なら order_no と line_no の組み合わせで1行です。
Laravelでこのテーブルを触ると、最初に起きる混乱は「マイグレーションでは複合主キーを作れるのに、Eloquentでは同じ感覚で保存できない」ことです。 ここを曖昧にしたまま記事を読むと、途中で必ず止まります。
複合主キーのテーブルでは、DBの一意性とEloquentのモデル識別を分けて考えます。DBでは order_no + line_no で一意でも、Eloquentの save() や delete() は単一主キーの前提で動きます。
この記事では、受注明細テーブルを使って、実際にどこでエラーになるのかを先に見ます。 そのうえで、Eloquentで読む処理、Query Builderで明示する処理、DB設計で逃がす処理を分けます。
受注明細テーブルで再現する
DBではorder_noとline_noで1行になる
題材は、受注明細テーブルです。 受注番号だけでは複数行あります。行番号だけでも他の受注と衝突します。
Schema::create('order_lines', function (Blueprint $table) {
$table->string('order_no', 20);
$table->unsignedInteger('line_no');
$table->string('product_code', 30);
$table->integer('qty');
$table->integer('unit_price');
$table->timestamps();
$table->primary(['order_no', 'line_no']);
});このテーブルなら、DBは次の重複を止めます。
order_no | line_no | product_code | qty | unit_price
---------|---------|--------------|-----|-----------
SO-1001 | 1 | P-001 | 2 | 1200
SO-1001 | 2 | P-002 | 1 | 3000
SO-1002 | 1 | P-001 | 5 | 1200SO-1001 の line_no = 1 は1行だけです。 業務上も、問い合わせでは「SO-1001の1行目」と呼べます。
Eloquentはid列があるつもりで動く
何も指定しないEloquentモデルは、主キー名を id と見なします。 でも、さきほどの order_lines テーブルには id 列がありません。
class OrderLine extends Model
{
protected $table = 'order_lines';
protected $guarded = [];
}このモデルで1行を取って、数量を変えて保存してみます。
$line = OrderLine::query()
->where('order_no', 'SO-1001')
->where('line_no', 1)
->firstOrFail();
$line->qty = 3;
$line->save();読み取りは通ることがあります。 where('order_no')->where('line_no') でSELECTしているからです。
問題は保存時です。 Eloquentはモデルを保存するときに、そのモデルの主キーで更新対象を作ろうとします。 主キー名を指定していなければ id を使います。
Eloquent単体とSQLiteで最小再現すると、例外は出ず、次のSQLが実行されました。
update "order_lines"
set "qty" = ?, "updated_at" = ?
where "id" is null更新件数は0件で、DBの qty は変わりません。 DBや設定によっては、id 列がないことでSQLエラーになることもあります。どちらにしても、意図した order_no + line_no で更新されていません。
ここで詰まる理由は、SELECT時の条件と、保存時にEloquentが使う条件が違うからです。 読み取りでは order_no と line_no を書いていても、save() ではEloquentが主キーを組み立て直します。
tomo「whereで1行取れたからsaveも同じ条件で更新するはず」と思うと外します。Eloquentは、取得に使ったwhere条件を保存時のキーとして覚えてくれるわけではありません。
primaryKeyに配列を入れると別のエラーになる
では、モデルへ複合主キーを配列で書けばよいのでしょうか。 こう書きたくなります。
class OrderLine extends Model
{
protected $table = 'order_lines';
protected $primaryKey = ['order_no', 'line_no'];
public $incrementing = false;
protected $keyType = 'string';
protected $guarded = [];
}この状態で同じように保存すると、今度は別の場所で壊れます。 LaravelやPHPのバージョンによって文言は変わりますが、典型的には次のようなエラーです。
Illegal offset typePHP 8系では、次のような文言になることがあります。
Cannot access offset of type array on arrayなぜこうなるか。 Eloquentの保存処理は、主キー名を文字列として扱う前提で、元の属性からキー値を取り出します。 ところが $primaryKey に配列を入れると、主キー名そのものが配列になります。
イメージとしては、次のようなことが起きます。
$keyName = ['order_no', 'line_no'];
// 本当は $original['id'] のように文字列キーを想定している
$value = $original[$keyName];配列を配列のキーとして使おうとして、Illegal offset type 系のエラーになります。 つまり、$primaryKey に配列を入れるだけでは、Eloquentの保存処理は複合主キー対応になりません。
複合主キーで詰まる場所は、テーブル作成ではなく、モデルを保存や削除に使うところです
Eloquentで読める範囲
一覧表示はEloquentでも書ける
複合主キーのテーブルでも、一覧表示ならEloquentを使える場面があります。 ポイントは、Eloquentモデルを「保存する1行」として扱わず、検索結果を表示するために使うことです。
class OrderLine extends Model
{
protected $table = 'order_lines';
public $incrementing = false;
public $timestamps = true;
protected $guarded = [];
}受注番号ごとの明細一覧は、次のように書けます。
$lines = OrderLine::query()
->where('order_no', $orderNo)
->orderBy('line_no')
->get();この処理では、Eloquentに「複合主キーで1行を保存してくれ」と頼んでいません。 ただのSELECTです。 だから、クエリスコープやアクセサを使って画面表示を読みやすくする余地があります。
class OrderLine extends Model
{
protected $table = 'order_lines';
public function scopeForOrder($query, string $orderNo)
{
return $query->where('order_no', $orderNo);
}
}$lines = OrderLine::query()
->forOrder($orderNo)
->orderBy('line_no')
->get();Eloquentを使うなら、まず読み取り中心の範囲に閉じると判断しやすくなります。
単一キーのマスタ参照はEloquentに寄せやすい
受注明細から商品マスタを引くような関係なら、単一キーでつながるためEloquentで書きやすいです。
class OrderLine extends Model
{
protected $table = 'order_lines';
public function product()
{
return $this->belongsTo(Product::class, 'product_code', 'product_code');
}
}これは、受注明細そのものを複合主キーで保存する話ではありません。 product_code という1列で商品マスタを参照しているだけです。
このように、同じモデルの中でも、Eloquentに寄せてよい部分と危ない部分があります。 全部を禁止すると画面側が重くなります。全部をEloquentへ寄せると保存で詰まります。



複合主キーのテーブルでも、Eloquentを読むために使うのは悪くありません。危ないのは、読み取ったモデルをそのまま保存や削除へ流すことです。
複合キーのリレーションは別扱いにする
受注番号と行番号の両方で別テーブルへつなぐ場合、通常のEloquentリレーションでは苦しくなります。 たとえば、明細ごとの検品結果が order_no + line_no でつながるような形です。
Schema::create('inspection_results', function (Blueprint $table) {
$table->string('order_no', 20);
$table->unsignedInteger('line_no');
$table->string('result', 20);
$table->timestamps();
$table->primary(['order_no', 'line_no']);
});この関係をEloquentの標準リレーションだけで自然に書こうとすると、複数カラムを同時に対応させるところで止まります。 whereColumn や明示JOINで取るか、複合リレーション対応のパッケージを検討するか、画面用の取得処理として切り出します。
$lines = DB::table('order_lines')
->leftJoin('inspection_results', function ($join) {
$join->on('order_lines.order_no', '=', 'inspection_results.order_no')
->on('order_lines.line_no', '=', 'inspection_results.line_no');
})
->where('order_lines.order_no', $orderNo)
->select([
'order_lines.order_no',
'order_lines.line_no',
'order_lines.product_code',
'inspection_results.result',
])
->orderBy('order_lines.line_no')
->get();複合キーのリレーションをパッケージで書けるようにしても、更新や削除まで安全になるとは限りません。参照が楽になる話と、データを変更する責任範囲は分けて確認します。
Query Builderで変える処理
更新は複合キーをWHERE句へ出す
数量や単価を変える処理では、Query Builderで更新対象を明示します。 order_no と line_no の両方をWHERE句に出します。
$affected = DB::table('order_lines')
->where('order_no', $orderNo)
->where('line_no', $lineNo)
->update([
'qty' => $qty,
'unit_price' => $unitPrice,
'updated_at' => now(),
]);更新件数も確認します。 対象は1件のはずです。0 件なら、対象行がない、すでに削除された、別の状態条件で弾かれた、などを調べます。
if ($affected !== 1) {
throw new RuntimeException('受注明細の更新対象が1件ではありません。');
}この書き方にすると、レビュー時に「どの列で1行を特定しているか」が見えます。 Eloquentの内部に主キー解決を任せないため、複合主キーの前提をコードで確認できます。
削除は片方のキーだけで書かない
削除はさらに注意します。 order_no だけで削除すると、その受注の全明細が消えます。
DB::table('order_lines')
->where('order_no', $orderNo)
->delete();1行削除なら、行番号まで条件に含めます。
$deleted = DB::table('order_lines')
->where('order_no', $orderNo)
->where('line_no', $lineNo)
->delete();出荷済みや締め済みを削除させないなら、状態条件も同じSQLに入れます。
$deleted = DB::table('order_lines')
->where('order_no', $orderNo)
->where('line_no', $lineNo)
->where('shipment_status', 'before_shipment')
->delete();複合キーの片方だけで削除できるコードは、事故の入口です 画面上は「1行削除」に見えても、SQLでは複数行を対象にしていることがあります。
Repositoryに寄せて条件を散らさない
Query Builderで書くからといって、コントローラーに where を何度も並べる必要はありません。 複合キーで探す処理、更新する処理をRepositoryやServiceに寄せます。
class OrderLineRepository
{
public function find(string $orderNo, int $lineNo): ?object
{
return DB::table('order_lines')
->where('order_no', $orderNo)
->where('line_no', $lineNo)
->first();
}
public function updatePrice(
string $orderNo,
int $lineNo,
int $qty,
int $unitPrice
): int {
return DB::table('order_lines')
->where('order_no', $orderNo)
->where('line_no', $lineNo)
->update([
'qty' => $qty,
'unit_price' => $unitPrice,
'updated_at' => now(),
]);
}
}ここまで切り出すと、コントローラーは複合キーのSQLを直接知りません。 ただし、Repositoryの中ではキー条件を省略しません。
Query Builderを使うことと、SQLをアプリ中に散らすことは別です。 複合キーの条件は明示しつつ、置き場所を決めます。
id追加で逃がせる場面
新規設計ならidと複合uniqueを分ける
新しくLaravelで作るテーブルなら、id を主キーにして、業務キーは複合uniqueで守る設計も候補になります。 Eloquentは id でモデルを識別でき、DBは order_no + line_no の重複を止められます。
Schema::create('order_lines', function (Blueprint $table) {
$table->id();
$table->string('order_no', 20);
$table->unsignedInteger('line_no');
$table->string('product_code', 30);
$table->integer('qty');
$table->integer('unit_price');
$table->timestamps();
$table->unique(['order_no', 'line_no']);
});この形なら、Eloquentの find()、save()、delete() は id を使えます。 業務上の重複は、複合uniqueで守ります。
$line = OrderLine::findOrFail($id);
$line->qty = 3;
$line->save();新規開発では、この形の方がLaravelに寄せやすいです。 アプリの識別子と、業務上の一意条件を分けられるからです。
運用中の業務キーは勝手に消さない
すでに動いているテーブルでは、id を足せば終わりとは限りません。 帳票、外部連携、CSV、バッチ、保守SQLが、order_no + line_no を前提にしていることがあります。
id を足しても、外部システムが送ってくるキーが受注番号と行番号なら、業務キーは残ります。 問い合わせ対応でも、利用者は「id 12345」ではなく「SO-1001の1行目」と言います。
この場合、まず影響を棚卸しします。
| 確認対象 | 見ること | 理由 |
|---|---|---|
| 画面 | URLやhidden値でどのキーを持つか | 更新対象の特定に関わる |
| 帳票 | 明細番号や枝番を出力しているか | 利用者が業務キーで照合する |
| 外部連携 | 連携ファイルやAPIのキー項目 | 相手側のキーは簡単に変えられない |
| バッチ | 更新と削除のWHERE条件 | 条件不足の更新事故を見つける |
| 保守SQL | 調査時にどのキーで検索しているか | 障害調査の手順が変わる |



id を足すかどうかは、Laravelの好みだけで決めません。業務でその行をどう呼び、どの連携がそのキーを使うかを見ると、変えてよい範囲が見えてきます。
DB制約を画面バリデーションの代わりにしない
id を足した場合でも、order_no + line_no の一意性はDBで守ります。 画面のバリデーションだけで重複を防ごうとすると、CSV取込やAPI、バッチから重複が入る可能性があります。
$table->unique(['order_no', 'line_no']);複合主キーをやめる場合でも、業務キーの一意性を捨てるわけではありません。 主キーを id にするなら、業務キーはunique制約へ移します。
レビューで見る境界
まず処理ごとに分ける
複合主キーのテーブルに出会ったら、最初に処理を分けます。 テーブル単位で「Eloquentを使う」「使わない」と決めるより、事故が減ります。
| 処理 | 寄せ方 | 確認すること |
|---|---|---|
| 一覧検索 | Eloquent寄り | 読み取りで閉じているか |
| 詳細参照 | 条件次第 | 複合キー取得メソッドを用意しているか |
| 1件更新 | Query Builder寄り | 複合キーと状態条件がWHERE句にあるか |
| 削除 | Query Builder寄り | 削除件数を確認しているか |
| 複合キーのリレーション | 慎重に判断 | JOINやパッケージの保守範囲を見たか |
| 新規設計 | id + 複合uniqueも検討 | Eloquentの識別子と業務キーを分けるか |
| 運用中テーブルの変更 | 影響調査してから判断 | 帳票、連携、バッチ、保守SQLを確認したか |
この表があると、レビューで見る場所が決まります。 「動いたか」だけではなく、「どの処理をEloquentに寄せ、どの処理をQuery Builderに逃がしたか」を確認できます。
エラー例をテストに残す
複合主キーの扱いは、口頭説明だけだと忘れられます。 チームで同じ判断を保つなら、テストやメモにエラー例を残します。
NG例:
- id列がない複合主キーテーブルでEloquentのsave()を使う
- $primaryKeyに配列を入れてEloquent保存処理を期待する
- order_noだけで受注明細をdeleteする
OK例:
- 一覧検索はEloquentで読む
- 更新と削除はQuery Builderでorder_noとline_noを明示する
- 新規設計ではidと複合uniqueを検討するSQLログやエラー文も残します。 次に同じ現象が出たとき、「複合主キーの扱いでは」とすぐ当たりを付けられるからです。
update "order_lines" set ... where "id" is null
Illegal offset type
Cannot access offset of type array on array


エラー文を調査メモに残さないと、自分の画面で起きた現象と結び付けられません。複合主キーの調査では、ここを省くと一気に分からなくなります。
最後に境界を決める
複合主キーのテーブルでは、Laravelで「できる書き方」を探す前に、処理ごとの境界を決めます。 読み取りはEloquent、更新と削除はQuery Builder、業務キーの一意性はDB制約で守る。 この分け方なら、Eloquentの便利さを使いつつ、保存処理の事故を避けやすくなります。
複合主キーで詰まったら、まずエラーの出る場所を確認します。id 列を探して落ちているのか、$primaryKey の配列で落ちているのか。そこを見てから、Eloquentで読む範囲とQuery Builderで変える範囲を決めます。
まとめ
Laravelで複合主キーのテーブルを扱うときは、最初に「Eloquentでどう動かすか」だけを考えると迷います。 DBでは複数列で1行を特定できても、Eloquentの保存や削除は単一主キーを前提に動くためです。
実装前に押さえる要点は次の通りです。
order_no + line_noのような複合主キーは、受注明細や履歴系の業務テーブルでは普通に出てきます。- マイグレーションで複合主キーを作れることと、Eloquentモデルが複合主キーで
save()できることは別です。 id列がないモデルでsave()すると、環境によってはwhere "id" is nullのような更新になり、意図した行が変わりません。$primaryKeyに配列を入れるだけでは、Illegal offset typeやCannot access offset of type array on arrayのようなエラーにつながります。- 一覧表示や検索のような読み取り中心の処理は、Eloquentに寄せても扱える場面があります。
- 1件更新、削除、状態条件を伴う変更は、Query Builderで
order_noとline_noをWHERE句に明示します。 - 新規設計なら、
idを主キーにして、業務キーは複合uniqueで守る形も検討します。 - すでに動いている業務テーブルでは、帳票、外部連携、バッチ、保守SQLがどのキーを使っているかを確認してから変えます。
複合主キーの実装で大事なのは、「できる」「できない」で終わらせないことです。 エラー文、実行されたSQL、更新件数、削除条件まで見て、どの処理をEloquentに寄せ、どの処理をQuery Builderで明示するかを決めます。




