Laravel

Laravel upsertの使い方|uniqueBy指定とupdateOrCreateとの使い分け

Laravel upsertの使い方|uniqueBy指定とupdateOrCreateとの使い分け

Laravelで「あれば更新、なければ挿入」を書く方法は4つあります。Eloquentのupsert、updateOrCreate、firstOrCreate、そしてクエリビルダのupdateOrInsertです。名前は似ていますが、発行されるSQLの本数も、モデルイベントが動くかどうかも、キャストが効くかどうかも違います。この記事では13.x系(実測時点の最新タグはv13.29.0・2026年8月25日公開)のソースを読み、3つの引数がどこに効くのか、MySQLとPostgreSQLで何が変わるのか、数万件を一度に流すときにどこで詰まるのかを、行番号つきで確かめた内容にもとづいて整理します。

まとめ:upsertを選ぶ条件と1件ずつ書く方式へ戻す境界

先に結論を置きます。upsertは「複数行をSQL1本で書き込む」ためのメソッドで、その代償としてモデル層の機能をほぼ全部落とします。落としてよい場面かどうかが選択基準です。

  • 行数が2件以上あり、キャスト・ミューテータ・モデルイベント・オブザーバーのどれも要らないならupsert。CSVやAPIからの定期取り込みが典型です
  • 1件だけを扱い、更新後のモデルインスタンスが欲しいならupdateOrCreate。SELECTを1本余分に払う代わりに、通常のsaveと同じ副作用が全部つきます
  • すでにある行には触れず、無い時だけ作りたいならfirstOrCreate。内部のcreateOrFirstが一意制約違反を捕まえて再取得するため、同時実行に強い書き方になっています
  • モデルを介さずテーブルを直接書き換えるだけならupdateOrInsert。ただしタイムスタンプは自分で入れます

逆に、upsertを見送るべき境界も先に示します。更新のたびに監査ログを残す設計、暗号化キャストやAttributeによる値変換が入っている列、ソフトデリート済みの行を復活させたくないテーブル。この3つのどれかに当たるなら、件数が多くても1件ずつ書く方式を選びます。理由は本文の最終章で条件つきに書きました。

upsertの3つの引数が挿入と更新の分岐を決める仕組みを理解する

Eloquentのupsertは3つの引数を取ります。値の配列、行を一意に識別する列、そして既存行が見つかったときに更新する列です。公式ドキュメントのFlightの例に沿うと、次の呼び出しになります。

Flight::upsert([['departure' => 'Oakland', 'destination' => 'San Diego', 'price' => 99]], uniqueBy: ['departure', 'destination'], update: ['price'])

この1本で、departureとdestinationの組が既にあればpriceだけを更新し、無ければ行ごと挿入します。SELECTは発行されません。判定を担うのはアプリケーション側ではなくデータベースの一意索引です。ここがupdateOrCreateとの構造的な違いで、後述する落とし穴のほとんどはこの一点から派生します。Eloquentそのものの成り立ちはLaravel Eloquentの基礎を整理した記事にまとめています。

第1引数の値配列で行ごとのキー順と列の欠けが招く不整合を防ぐ方法

第1引数には連想配列の配列を渡します。1行だけなら平坦な連想配列でも受け付け、内部で配列に包み直されます(Query Builder側の実装で4446行目)。複数行を渡した場合、各行はksortでキー順が揃えられます。順序が違うだけの配列を渡しても問題は起きません。

問題は「列が欠けている行」です。生成されるINSERT文の列名は先頭行のキーから決まります。2行目以降に先頭行と違うキーが混ざっていると、バインドされる値の並びがずれ、列数不一致のSQLエラーか、最悪の場合は別の列に値が入ります。外部データから配列を組み立てるときは、全行で同じキー集合になるようデフォルト値で埋めてから渡してください。array_mapで各行をarray_merge($template, $row)に通すだけで防げます。

第2引数uniqueByの指定と空配列を渡したときの例外の原因

第2引数は「どの列の組み合わせで既存行を探すか」です。単一列なら文字列、複合キーなら配列で渡します。空配列や空文字を渡すとInvalidArgumentExceptionが飛び、メッセージは「The unique columns must not be empty.」です(Query Builderの4436行目)。設定値やリクエストからキー列名を組み立てている実装では、値が空になった瞬間にここで落ちます。

この引数はSQL上ではPostgreSQLとSQLiteのon conflict句の競合ターゲットに、SQL Serverではmerge文のon条件に変換されます。MySQLとMariaDBだけは扱いが違い、次の章で詳しく見ます。

第3引数updateの省略時と空配列を渡した時に変わる分岐の注意点

第3引数を省略(null)すると、更新列は「第1引数の先頭行のキー全部」になります。Eloquent Builderの1325行目でこの既定値が決まる点が効いてきます。ここで確定した更新列の一覧に対し、あとからupdated_atだけが追加される順序になっているため、明示的にcreated_atを値へ書いていない限り、既存行の作成日時が上書きされることはありません。

一方、空配列を渡した場合はまったく別の挙動になります。Query Builderの4442行目でinsertへフォールバックし、競合処理の句が丸ごと消えるのです。「更新はしたくないから更新列を空にする」と書くと、既存行に当たった瞬間に一意制約違反の例外が飛びます。挿入のみが目的ならinsertOrIgnoreを使ってください。

updateOrCreateとfirstOrCreateとの発行クエリ数と副作用の差

4つのメソッドは「何本のSQLを投げるか」「モデルを経由するか」で性格が分かれます。実装を追うと差がはっきりします。

updateOrCreateがSELECTと書き込みを分けて発行する内部実装

Eloquent Builderの766行目のupdateOrCreateは、firstOrCreateを呼んだうえで、返ってきたモデルのwasRecentlyCreatedが偽ならfillしてsaveする構造です。つまり既存行が当たった場合はSELECT1本とUPDATE1本、無かった場合はSELECTとINSERTになります。返り値はモデルインスタンスなので、その場でリレーションを辿ったりレスポンスに載せたりできます。

副作用も通常のsaveと同じです。savingとsaved、新規ならcreatingとcreatedのイベントが飛び、オブザーバーも動作の対象です。キャストとミューテータも当然効きます。1件ずつの処理でこれらが要るなら、クエリ本数の差は払う価値があります。

firstOrCreateがcreateOrFirstで一意制約違反を拾う競合対策

firstOrCreateは732行目で、まずSELECTし、見つからなければ750行目のcreateOrFirstに委ねます。このcreateOrFirstが面白い作りで、INSERTを試み、UniqueConstraintViolationExceptionを捕まえたら書き込み用PDOで再度SELECTして返します。トランザクションの中にいる場合はwithSavepointIfNeededでセーブポイントを張るため、失敗した挿入が外側のトランザクションを巻き添えにしません。

SELECTと INSERTの間に別プロセスが同じ行を作る、という典型的な競合を、例外を握って吸収する設計です。「存在すれば取得し無ければ作る」処理を並行実行するバッチでfirstOrCreateが推されるのはこの構造によります。ただし一意索引が張られていないと例外自体が飛ばないため、この安全性はスキーマ側の定義に依存します。

4メソッドの発行クエリとイベント発火を並べた選択の目安と判断軸

メソッド SQL本数 複数行 モデルイベント キャスト 返り値
upsert 1本 対応 発火しない 効かない 影響行数
updateOrCreate 2本 1行ずつ 発火する 効く モデル
firstOrCreate 1〜2本 1行ずつ 発火する 効く モデル
updateOrInsert 2本 1行ずつ 発火しない 効かない 真偽値

実務でまず押さえるのは1行目と2行目の対比です。100件の取り込みをupdateOrCreateのループで書くと200本のクエリになり、upsertなら1本です。逆に言えば、イベントとキャストが要る処理を件数だけを理由にupsertへ寄せると、静かに副作用が消えます。クエリビルダのupdateOrInsertは第2引数にクロージャを渡せて、fn ($exists) => $exists ? [...] : [...]の形で挿入時だけ既定値を足す書き分けができます。

一意インデックスの要否とMySQLで第2引数が無視される仕様

公式ドキュメントはこの2文を並べています。SQL Server以外のすべてのデータベースは第2引数の列に主キーまたは一意索引を要求すること。そしてMariaDBとMySQLのドライバは第2引数を無視し、テーブルの主キーと一意索引を常に見ること。この2文が、開発環境と本番環境で挙動が食い違う事故の原因になります。

MySQLとMariaDBが第2引数を無視して表の索引を見る挙動

MySqlGrammarの426行目を読むと、生成されるのはon duplicate key update句です。この構文には競合ターゲットを指定する場所がありません。だから第2引数は捨てられ、テーブルに張られたすべての一意索引が判定に使われます。

ここで何が起きるか。emailとtenant_idの2つに別々の一意索引があるテーブルで、uniqueByにtenant_idだけを指定したとします。開発者の意図は「テナント単位で更新」ですが、MySQLはemailの重複でも既存行と見なして更新します。意図しない行が書き換わっても例外は出ません。MySQLを使う限り、upsertの対象テーブルには判定に使いたい一意索引だけを残す、という設計の制約がつきます。

PostgreSQLの部分一意索引にon conflictが当たらない場面

PostgresGrammarの487行目はon conflict (列名) do update setを組み立て、更新値の参照元はexcludedです。競合ターゲットが明示される分、MySQLより意図どおりに動きます。ただし部分一意索引(WHERE deleted_at IS NULLのような条件つき索引)を張っている場合、PostgreSQL側の要件としてon conflict句にも同じ述語が必要です。Laravelのupsertにはこの述語を渡す口が無いため、部分一意索引を推論できずエラーになります。

回避策は2つです。述語なしの一意索引に変えるか、DB::statementで生SQLを書くか。ON CONFLICT句そのものの書き方と、PostgreSQL 15以降のMERGE文との使い分けはPostgreSQLのUPSERT実装を扱った記事で詳しく整理しています。

SQLiteのテストだけが落ちるときに疑う索引定義の欠落と対処法

「本番のMySQLでは動くのに、SQLiteのテストでだけ落ちる」。この症状はほぼ索引定義の欠落です。MySQLは第2引数を見ないので、指定した列に索引が無くても他の索引で動いてしまう。SQLiteとPostgreSQLは指定された列そのものに一意制約を要求するため、そこで初めて表面化します。

マイグレーションに$table->unique(['departure', 'destination'])が書かれているかを先に確認してください。テスト環境をSQLiteにしておくと、この種の定義漏れを本番より先に検出できるという副次的な利点があります。マイグレーションの書き方はマイグレーションの基本を扱った記事を参照してください。

timestampsとキャストがupsertで外れる条件と補い方

「upsertを使ったらcreated_atがnullで落ちた」という詰まり方は、Eloquent経由かクエリビルダ経由かで結論が分かれます。ソースを読むと境界がはっきりします。

created_atとupdated_atがEloquent側で補われる実装の順序

Eloquent Builderの1490行目addTimestampsToUpsertValuesは、モデルがusesTimestampsを返す場合に各行へcreated_atとupdated_atを差し込みます。差し込みはarray_merge([$column => $timestamp], $row)で行われるため、呼び出し側が明示的に値を入れていればそちらが勝ちます。

更新列の側は1518行目のaddUpdatedAtToUpsertColumnsが担当し、updated_atが更新列に無ければ末尾へ足します。created_atは足されません。この非対称が正しい挙動を生みます。既存行は更新日時だけが進み、作成日時は初回の値のまま残る。ただし冒頭で触れたとおり、第3引数を省略したうえで値側に自分でcreated_atを書くと、それが更新列に含まれてしまい作成日時が上書きされます。

DBファサード経由のupsertでタイムスタンプが入らない事故

DB::table('flights')->upsert(...)にはこの補完がありません。クエリビルダはモデルを知らないからです。created_atとupdated_atがNOT NULLなら制約違反で即死し、NULL許容ならnullのまま入って後から気づきます。後者のほうが厄介です。

クエリビルダを使う理由がモデルの回避ではなく単に書き慣れているだけなら、モデル経由に戻すのが確実です。どうしてもクエリビルダで書くなら、値の組み立て時にnow()を明示的に入れ、更新列にもupdated_atを含めてください。

キャストとミューテータとオブザーバーが動かない範囲を把握する

upsertはモデルをインスタンス化しません。結果として次が全部無効になります。castsによる型変換(配列をJSON文字列に直す処理を含む)、Attributeで定義したミューテータ、暗号化キャスト、そしてsavingからupdatedまでのモデルイベントとオブザーバー。UUIDだけは例外で、1467行目のaddUniqueIdsToUpsertValuesがHasUuidsやHasUlidsのuniqueIdsを値側に補います。

配列やオブジェクトを渡している列は、自分でjson_encodeしてから配列に積む必要があります。暗号化キャストの列をupsertで書くと、平文がそのままデータベースに入ります。値変換を伴う列がテーブルに1つでもあるなら、その列をupsertの対象から外すか、メソッド自体を変えてください。取得後のコレクション操作についてはCollectionの値の追加と検索を扱った記事が対応します。

数万件のバルク処理でパラメータ上限を避ける分割設計の実務対応

Laravelのupsertは内部で件数分割をしません。渡された行を全部1本のINSERT文に展開します。数千件を超えたあたりからデータベース側の制限に当たります。

SQLiteの32766とPostgreSQLのプロトコル由来の上限値

SQLiteのSQLITE_MAX_VARIABLE_NUMBERは、3.32.0(2020年5月22日)以降の既定値が32766です。それ以前は999でした。PostgreSQLは拡張問い合わせプロトコルがパラメータ数を16ビットで表現するため、1文あたり65535個が上限になります。MySQLはプレースホルダ数の上限に加えてmax_allowed_packetによるサイズ制限も受けます。

上限に対して必要な数は「行数 × 列数」です。10列のテーブルなら、SQLiteは約3200行、PostgreSQLは約6500行で壁に当たります。タイムスタンプ2列が自動で足されることも計算に入れてください。エラーメッセージはドライバごとに違い、SQLiteは「too many SQL variables」、PostgreSQLはパラメータ数超過を示す例外です。

array_chunkでの分割件数の決め方とトランザクションの粒度

実装は単純です。collect($rows)->chunk(1000)あるいはarray_chunk($rows, 1000)でループし、各チャンクでupsertを呼びます。1000という数字は、列数10前後のテーブルでSQLiteの上限に対しても余裕が残る値として選んでいます。列が30を超えるテーブルなら300程度まで落としてください。

トランザクションの粒度は要件で決まります。取り込み全体を原子的に扱いたいならDB::transactionでループ全体を囲みますが、数万件を1トランザクションに入れるとロック保持時間が延び、MySQLではundoログが膨らみます。途中失敗時に部分適用を許容できる取り込みなら、チャンクごとにトランザクションを閉じるほうが運用は楽です。再実行しても同じ結果になるのがupsertの性質なので、失敗したチャンクから流し直せる設計にしておきます。

MySQLのuse_upsert_aliasで非推奨のvalues記法を避ける設定

MySqlGrammarの428行目は、接続設定のuse_upsert_aliasを読みます。真ならas laravel_upsert_aliasという行エイリアスを付け、更新値をそのエイリアス経由で参照する仕組みです。偽ならvalues(列名)という古い記法を出力します。

MySQL 8.4のリファレンスマニュアルは、ON DUPLICATE KEY UPDATE内で新しい行を参照するvalues()を非推奨と明記し、将来の版で削除される可能性があるとしたうえで行エイリアスへの置換を勧めています。MySQL 8系を使っているなら、config/database.phpのmysql接続に'use_upsert_alias' => trueを足しておくと、将来の版上げで警告や失敗に変わる箇所を先に潰せます。既存の動作は変わりません。

upsertを見送る条件と受託開発での書き分けの判断基準を整理する

ここからは判断の話です。件数が多ければupsert、という単純な基準では事故が起きます。件数ではなく「モデル層の機能を落としてよいか」で切り分けます。

件数が多くてもupsertを見送る3つの具体的な条件を見極める

1つ目は監査ログです。updatedイベントやオブザーバーで変更履歴を残しているテーブルは、upsertにすると履歴が欠落します。しかもエラーは出ません。監査要件のあるテーブルでは、件数が1万件でもupdateOrCreateのループを選び、遅ければキューに逃がします。

2つ目は値変換を伴う列です。暗号化キャスト、Attributeによるミューテータ、配列とJSONの相互変換。これらが1列でもあるなら、自前でエンコードしてまでupsertを通す価値はありません。エンコード漏れは静かにデータを壊します。

3つ目はソフトデリートを使うテーブルです。upsertはSQLを直接投げるので、グローバルスコープによるdeleted_at is nullの絞り込みは効きません。削除済みの行が一意キーで当たれば、その行も更新対象です。deleted_atを更新列に含めない限り削除済みのまま値だけが書き換わり、画面には出ないのにデータは更新されている状態になります。スコープの効く範囲についてはクエリスコープの使い方を扱った記事が参考になります。

外部データの定期取り込みにupsertを据える設計の型と運用手順

逆に、迷わずupsertを選ぶのが外部データの定期取り込みです。基幹システムからのCSV連携、SaaSのAPIから引く商品マスタ、夜間バッチでの在庫同期。これらは「取り込み先のテーブルが外部の写しである」という点が共通しています。写しにモデルイベントは要りません。

設計の型はこうです。取り込み先を専用テーブルに分け、外部側のIDに一意索引を張る。uniqueByにはその外部IDだけを指定する。更新列には外部から来る列だけを並べ、自社側で付けたフラグ列は更新列に入れない。1000件ごとにチャンクして流し、チャンク単位でトランザクションを閉じる。これで再実行可能な取り込みになります。同じ「何度流しても同じ結果になる」考え方を初期データの投入側へ持ち込むなら、Laravel Seederの使い方|Factory連携と本番投入してよいデータの線引きで本番マスタ用シーダーの書き方をまとめています。

受託開発では、この「専用テーブルに分ける」判断が後の保守コストを大きく左右します。業務側のマスタと取り込み先を同じテーブルにすると、モデルイベントが要る列と要らない列が同居し、どのメソッドで書くべきかが列ごとに変わってしまうためです。業務用・Webアプリ開発では、既存の基幹システムとの連携を含む要件でこうしたテーブル分割から設計しています。

よくある質問

upsertの実装で実際に詰まりやすい箇所を5つ挙げます。

upsertは1件だけの更新にも使えますか?

使えます。第1引数に平坦な連想配列を渡すと、内部で1要素の配列に包まれます。ただし1件だけならモデルインスタンスが返るupdateOrCreateのほうが扱いやすい場面が多く、SQLが1本増えるだけです。キャストやモデルイベントが要らず、かつ返り値を使わないと決まっている場合に限ってupsertを選ぶ、という順序で考えてください。

upsertの戻り値がMySQLだけ件数と合わないのはなぜですか?

戻り値は「影響を受けた行数」であって処理した行数ではないためです。MySQLのON DUPLICATE KEY UPDATEは、挿入なら1、更新なら2を数えます。値がまったく変わらなかった場合は0です。3行を渡して全部更新されたら6が返ります。処理件数の確認に戻り値を使わず、渡した配列の要素数をログに残してください。

ソフトデリート済みの行はupsertでどう扱われますか?

削除済みかどうかに関係なく、一意キーが一致すれば更新対象になります。upsertはSQLを直接発行するのでグローバルスコープが適用されないからです。削除済みの行を復活させたいなら更新列にdeleted_atを含めてnullを入れる、復活させたくないなら一意索引の設計自体を見直すか、事前に対象IDを取得して分岐させます。

upsertでモデルのイベントやオブザーバーは動きますか?

動きません。モデルがインスタンス化されないためで、saving・saved・creating・created・updating・updatedのいずれも発火しません。一括更新や一括削除でイベントが飛ばないのと同じ理由です。イベント側で通知やキャッシュ破棄をしている場合は、upsertの呼び出し後に自分でその処理を呼ぶ設計に変えてください。

uniqueByに指定した列に索引が無いとどうなりますか?

MySQLとMariaDBは第2引数を見ないため、テーブルの他の一意索引で判定して動いてしまいます。PostgreSQLとSQLiteは指定された列に一意制約を求めるため、無ければエラーになります。SQL Serverだけはmerge文を生成するので索引が要りません。ドライバによって症状が変わるので、マイグレーション側で一意索引を明示しておくのが唯一の確実な対処です。

関連記事

お気に入りに入れた記事の一覧

この記事は以下の記事からリンクされています

資料請求

今日のトレンド記事 直近 24 時間で、いつもより多く読まれている記事

  1. 2026.10.09 テックブログ IDCFクラウド(IDCフロンティア)不正アクセス・ランサムウェア:影響先・復旧・データは戻るか
  2. 2026.10.09 テックブログ ユニリタの不正アクセス:SaaS文書管理から漏れた顧客の認証情報と回す手順
  3. 2026.10.09 テックブログ ニッスイのサイバー攻撃で日水物流の入出荷停止|委託先クラウド障害に荷主が備える手順
  4. 2026.10.08 テックブログ 大阪公立大学のランサムウェア被害と仮想化基盤の停止|全授業休講に至った経緯とバックアップを守る設定
  5. 2026.10.09 テックブログ 京王電鉄のランサムウェア被害とグループ共通基盤:決済・ポイント・予約が止まった範囲と遮断の初動

RELATED POSTS 関連記事

目次