業務システム開発・保守の実務メモを発信中

Laravel複合主キーでEloquentとQuery Builderを使い分ける

受注明細や在庫履歴では、1つの id ではなく、複数の列で1行を特定するテーブルがあります。 たとえば、受注明細なら order_noline_no の組み合わせで1行です。

Laravelでこのテーブルを触ると、最初に起きる混乱は「マイグレーションでは複合主キーを作れるのに、Eloquentでは同じ感覚で保存できない」ことです。 ここを曖昧にしたまま記事を読むと、途中で必ず止まります。

複合主キーのテーブルでは、DBの一意性とEloquentのモデル識別を分けて考えます。DBでは order_no + line_no で一意でも、Eloquentの save()delete() は単一主キーの前提で動きます。

この記事では、受注明細テーブルを使って、実際にどこでエラーになるのかを先に見ます。 そのうえで、Eloquentで読む処理、Query Builderで明示する処理、DB設計で逃がす処理を分けます。

目次

受注明細テーブルで再現する

DBではorder_noとline_noで1行になる

題材は、受注明細テーブルです。 受注番号だけでは複数行あります。行番号だけでも他の受注と衝突します。

PHP
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は次の重複を止めます。

Markdown
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   | 1200

SO-1001line_no = 1 は1行だけです。 業務上も、問い合わせでは「SO-1001の1行目」と呼べます。

Eloquentはid列があるつもりで動く

何も指定しないEloquentモデルは、主キー名を id と見なします。 でも、さきほどの order_lines テーブルには id 列がありません。

PHP
class OrderLine extends Model
{
    protected $table = 'order_lines';
    protected $guarded = [];
}

このモデルで1行を取って、数量を変えて保存してみます。

PHP
$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が実行されました。

Markdown
update "order_lines"
set "qty" = ?, "updated_at" = ?
where "id" is null

更新件数は0件で、DBの qty は変わりません。 DBや設定によっては、id 列がないことでSQLエラーになることもあります。どちらにしても、意図した order_no + line_no で更新されていません。

ここで詰まる理由は、SELECT時の条件と、保存時にEloquentが使う条件が違うからです。 読み取りでは order_noline_no を書いていても、save() ではEloquentが主キーを組み立て直します。

tomo

「whereで1行取れたからsaveも同じ条件で更新するはず」と思うと外します。Eloquentは、取得に使ったwhere条件を保存時のキーとして覚えてくれるわけではありません。

primaryKeyに配列を入れると別のエラーになる

では、モデルへ複合主キーを配列で書けばよいのでしょうか。 こう書きたくなります。

PHP
class OrderLine extends Model
{
    protected $table = 'order_lines';
    protected $primaryKey = ['order_no', 'line_no'];
    public $incrementing = false;
    protected $keyType = 'string';
    protected $guarded = [];
}

この状態で同じように保存すると、今度は別の場所で壊れます。 LaravelやPHPのバージョンによって文言は変わりますが、典型的には次のようなエラーです。

Markdown
Illegal offset type

PHP 8系では、次のような文言になることがあります。

Markdown
Cannot access offset of type array on array

なぜこうなるか。 Eloquentの保存処理は、主キー名を文字列として扱う前提で、元の属性からキー値を取り出します。 ところが $primaryKey に配列を入れると、主キー名そのものが配列になります。

イメージとしては、次のようなことが起きます。

PHP
$keyName = ['order_no', 'line_no'];

// 本当は $original['id'] のように文字列キーを想定している
$value = $original[$keyName];

配列を配列のキーとして使おうとして、Illegal offset type 系のエラーになります。 つまり、$primaryKey に配列を入れるだけでは、Eloquentの保存処理は複合主キー対応になりません。

複合主キーで詰まる場所は、テーブル作成ではなく、モデルを保存や削除に使うところです

Eloquentで読める範囲

一覧表示はEloquentでも書ける

複合主キーのテーブルでも、一覧表示ならEloquentを使える場面があります。 ポイントは、Eloquentモデルを「保存する1行」として扱わず、検索結果を表示するために使うことです。

PHP
class OrderLine extends Model
{
    protected $table = 'order_lines';
    public $incrementing = false;
    public $timestamps = true;
    protected $guarded = [];
}

受注番号ごとの明細一覧は、次のように書けます。

PHP
$lines = OrderLine::query()
    ->where('order_no', $orderNo)
    ->orderBy('line_no')
    ->get();

この処理では、Eloquentに「複合主キーで1行を保存してくれ」と頼んでいません。 ただのSELECTです。 だから、クエリスコープやアクセサを使って画面表示を読みやすくする余地があります。

PHP
class OrderLine extends Model
{
    protected $table = 'order_lines';

    public function scopeForOrder($query, string $orderNo)
    {
        return $query->where('order_no', $orderNo);
    }
}
PHP
$lines = OrderLine::query()
    ->forOrder($orderNo)
    ->orderBy('line_no')
    ->get();

Eloquentを使うなら、まず読み取り中心の範囲に閉じると判断しやすくなります。

単一キーのマスタ参照はEloquentに寄せやすい

受注明細から商品マスタを引くような関係なら、単一キーでつながるためEloquentで書きやすいです。

PHP
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へ寄せると保存で詰まります。

tomo

複合主キーのテーブルでも、Eloquentを読むために使うのは悪くありません。危ないのは、読み取ったモデルをそのまま保存や削除へ流すことです。

複合キーのリレーションは別扱いにする

受注番号と行番号の両方で別テーブルへつなぐ場合、通常のEloquentリレーションでは苦しくなります。 たとえば、明細ごとの検品結果が order_no + line_no でつながるような形です。

PHP
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で取るか、複合リレーション対応のパッケージを検討するか、画面用の取得処理として切り出します。

PHP
$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_noline_no の両方をWHERE句に出します。

PHP
$affected = DB::table('order_lines')
    ->where('order_no', $orderNo)
    ->where('line_no', $lineNo)
    ->update([
        'qty' => $qty,
        'unit_price' => $unitPrice,
        'updated_at' => now(),
    ]);

更新件数も確認します。 対象は1件のはずです。0 件なら、対象行がない、すでに削除された、別の状態条件で弾かれた、などを調べます。

PHP
if ($affected !== 1) {
    throw new RuntimeException('受注明細の更新対象が1件ではありません。');
}

この書き方にすると、レビュー時に「どの列で1行を特定しているか」が見えます。 Eloquentの内部に主キー解決を任せないため、複合主キーの前提をコードで確認できます。

削除は片方のキーだけで書かない

削除はさらに注意します。 order_no だけで削除すると、その受注の全明細が消えます。

PHP
DB::table('order_lines')
    ->where('order_no', $orderNo)
    ->delete();

1行削除なら、行番号まで条件に含めます。

PHP
$deleted = DB::table('order_lines')
    ->where('order_no', $orderNo)
    ->where('line_no', $lineNo)
    ->delete();

出荷済みや締め済みを削除させないなら、状態条件も同じSQLに入れます。

PHP
$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に寄せます。

PHP
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 の重複を止められます。

PHP
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で守ります。

PHP
$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調査時にどのキーで検索しているか障害調査の手順が変わる
tomo

id を足すかどうかは、Laravelの好みだけで決めません。業務でその行をどう呼び、どの連携がそのキーを使うかを見ると、変えてよい範囲が見えてきます。

DB制約を画面バリデーションの代わりにしない

id を足した場合でも、order_no + line_no の一意性はDBで守ります。 画面のバリデーションだけで重複を防ごうとすると、CSV取込やAPI、バッチから重複が入る可能性があります。

PHP
$table->unique(['order_no', 'line_no']);

複合主キーをやめる場合でも、業務キーの一意性を捨てるわけではありません。 主キーを id にするなら、業務キーはunique制約へ移します。

レビューで見る境界

まず処理ごとに分ける

複合主キーのテーブルに出会ったら、最初に処理を分けます。 テーブル単位で「Eloquentを使う」「使わない」と決めるより、事故が減ります。

処理寄せ方確認すること
一覧検索Eloquent寄り読み取りで閉じているか
詳細参照条件次第複合キー取得メソッドを用意しているか
1件更新Query Builder寄り複合キーと状態条件がWHERE句にあるか
削除Query Builder寄り削除件数を確認しているか
複合キーのリレーション慎重に判断JOINやパッケージの保守範囲を見たか
新規設計id + 複合uniqueも検討Eloquentの識別子と業務キーを分けるか
運用中テーブルの変更影響調査してから判断帳票、連携、バッチ、保守SQLを確認したか

この表があると、レビューで見る場所が決まります。 「動いたか」だけではなく、「どの処理をEloquentに寄せ、どの処理をQuery Builderに逃がしたか」を確認できます。

エラー例をテストに残す

複合主キーの扱いは、口頭説明だけだと忘れられます。 チームで同じ判断を保つなら、テストやメモにエラー例を残します。

Markdown
NG例:
- id列がない複合主キーテーブルでEloquentのsave()を使う
- $primaryKeyに配列を入れてEloquent保存処理を期待する
- order_noだけで受注明細をdeleteする

OK例:
- 一覧検索はEloquentで読む
- 更新と削除はQuery Builderでorder_noとline_noを明示する
- 新規設計ではidと複合uniqueを検討する

SQLログやエラー文も残します。 次に同じ現象が出たとき、「複合主キーの扱いでは」とすぐ当たりを付けられるからです。

Markdown
update "order_lines" set ... where "id" is null
Illegal offset type
Cannot access offset of type array on array
tomo

エラー文を調査メモに残さないと、自分の画面で起きた現象と結び付けられません。複合主キーの調査では、ここを省くと一気に分からなくなります。

最後に境界を決める

複合主キーのテーブルでは、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 typeCannot access offset of type array on array のようなエラーにつながります。
  • 一覧表示や検索のような読み取り中心の処理は、Eloquentに寄せても扱える場面があります。
  • 1件更新、削除、状態条件を伴う変更は、Query Builderで order_noline_no をWHERE句に明示します。
  • 新規設計なら、id を主キーにして、業務キーは複合uniqueで守る形も検討します。
  • すでに動いている業務テーブルでは、帳票、外部連携、バッチ、保守SQLがどのキーを使っているかを確認してから変えます。

複合主キーの実装で大事なのは、「できる」「できない」で終わらせないことです。 エラー文、実行されたSQL、更新件数、削除条件まで見て、どの処理をEloquentに寄せ、どの処理をQuery Builderで明示するかを決めます。

よかったらシェアしてね!
  • URLをコピーしました!
目次