CakePHPでループ中にsaveする — 2.xの常識は3以降で通用しない
foreach の中で save() を呼ぶ。よくある処理ですが、CakePHPはバージョンで作法が完全に変わりました。
2.x では「create() を呼ばないと1件目が上書きされ続ける」という有名な罠がありました。
3.x 以降はORMが作り直されたのでこの罠は消えています。
代わりに別の落とし穴ができました。
saveMany() はアトミックではありません。
公式ドキュメントにそう書いてあります。これを知らずに使うと、途中で失敗したときに中途半端なデータが残ります。
先に結論
| やりたいこと | 書き方 |
|---|---|
| 1件保存 | $table->save($entity) |
| 複数件まとめて | $table->saveMany($entities) |
| 全部成功か全部失敗にしたい | $table->getConnection()->transactional(...) で囲む |
| 配列からEntityを量産 | $table->newEntities($data) |
| 既存Entityをまとめて更新 | $table->patchEntities($entities, $data) |
一番大事なのは3行目です。
公式ドキュメントの saveMany() の説明にこうあります。
「すべてのエンティティーの保存で個別のトランザクションが走ります。
もし単一のトランザクションで すべてのエンティティーを処理したいのであれば、transactional() を使うことができます。」
つまり100件のうち50件目で失敗したら、49件は保存されたまま残ります。
| バージョン | ループ内saveの作法 | 状態 |
|---|---|---|
| 2.x | 毎回 create() を呼ぶ | EOL。新規では使わない |
| 3.x 以降 | Entityを毎回作る/saveMany() | 現行(5.x) |
2.x で何が起きていたか
当時のモデルは状態を持っていました。$this->Model->save() を続けて呼ぶと、
2回目以降は1回目に保存したレコードのIDが残ったままになり、
新規登録のつもりが同じ行のUPDATEになるという挙動でした。
2.x でよく見た書き方
foreach ($rows as $row) {
$this->Post->create(); // ← これが無いと上書きされる
$this->Post->save($row);
}
create() は「モデルの状態をリセットする」ための呼び出しでした。
言い換えると、モデルが状態を持っていたからこそ必要だったということです。
3.x でORMが作り直され、状態はEntityという別のオブジェクトに移りました。
だからcreate()に相当するものが要らなくなっています。
3.x 以降はどう書くか
Entityを毎回作る
ループのたびに新しいEntityを作る。これだけです。
1件ずつ保存する
$posts = $this->fetchTable('Posts');
foreach ($rows as $row) {
$post = $posts->newEntity($row); // 毎回あたらしく作る
$posts->save($post);
}
やってはいけないのは、Entityを使い回すことです。
ループの外で作った1つのEntityに対して patchEntity() で値を入れ替えながら保存すると、
2.xと同じことが起きます。Entityは主キーを保持しているので、
2回目以降はそのIDに対するUPDATEになります。
罠が消えたのではなく、罠の場所がモデルからEntityに移ったと考えてください。
saveMany でまとめる
件数が多いなら、1件ずつ save() するより saveMany() です。
まとめて保存する
$posts = $this->fetchTable('Posts');
// 配列からEntityをまとめて作る
$entities = $posts->newEntities($rows);
$result = $posts->saveMany($entities);
公式の説明はこうです。
このメソッドを使うと、複数のエンティティーを自動で保存することができます。
エンティティーは、newEntities() / patchEntities() で作成されたエンティティーの配列です。
| 単数 | 複数 | 用途 |
|---|---|---|
newEntity() | newEntities() | 新規作成 |
patchEntity() | patchEntities() | 既存のEntityに値を流し込む |
save() | saveMany() | 保存 |
saveMany はアトミックではない
ここが一番の落とし穴です。 名前からして「まとめて1回で保存する」ように読めますが、中身は違います。
公式の記載
すべてのエンティティーの保存で個別のトランザクションが走ります。
もし単一のトランザクションで すべてのエンティティーを処理したいのであれば、
transactional() を使うことができます。
「個別のトランザクションが走る」——つまり1件ごとにコミットされます。
途中で失敗すると何が残るか
| 状況 | 多くの人が期待する挙動 | 実際 |
|---|---|---|
| 100件中、50件目でバリデーション失敗 | 全部ロールバック(0件) | 49件が保存されたまま残る |
| 100件中、50件目でDBエラー | 全部ロールバック(0件) | 49件が保存されたまま残る |
| 再実行する | そのまま流せる | 49件が重複登録される恐れ |
これはバッチ処理で致命傷になります。 「失敗したからもう一度流す」をやると、最初の49件が二重に入ります。
CSV取り込みや一括登録で「全部入るか、1件も入らないか」が要件なら、
saveMany() を呼ぶだけでは要件を満たしていません。
transactional() で囲む
公式が案内しているのが transactional() です。
全部成功か、全部失敗か
$posts = $this->fetchTable('Posts');
$entities = $posts->newEntities($rows);
$ok = $posts->getConnection()->transactional(function () use ($posts, $entities) {
foreach ($entities as $entity) {
if (!$posts->save($entity, ['atomic' => false])) {
return false; // ← false を返すとロールバックされる
}
}
return true;
});
if (!$ok) {
// 1件も保存されていない状態
}
| コールバックが | transactional() の挙動 |
|---|---|
false を返した | ロールバックして false を返す |
| 例外を投げた | ロールバックして、例外を再スローする |
| それ以外を返した | コミットする |
例外が再スローされるのは重要です。
握りつぶされないので、呼び出し側で try/catch して記録できます。
逆に言うと、catchしないとそのまま上に飛びます。
戻り値とエラーの取り方
save() の戻り値は公式にこう書かれています。
成功時には変更されたエンティティーを返し、失敗時には false を返します。
if ($table->save($entity)) で判定できますが、「なぜ失敗したか」は戻り値から分かりません。
バリデーションエラーはEntity側に入っています。
失敗の理由を取る
if (!$posts->save($post)) {
// どの項目がどう失敗したかはEntityが持っている
$errors = $post->getErrors();
// 例: ['title' => ['_empty' => 'タイトルは必須です']]
}
| やりたいこと | 見るもの |
|---|---|
| 保存できたか | save() の戻り値(失敗は false) |
| なぜ失敗したか | $entity->getErrors() |
| ループでどれが失敗したか | 各Entityの getErrors() を集める |
ループ処理では「何件成功して、どれが失敗したか」をログに残してください。
saveMany() がアトミックでない以上、途中まで入った状態から復旧する必要が出てきます。
どこまで入ったかが分からないと、手作業になります。
そもそもループで保存する前に
ループの中でDBを触る処理は、件数が増えると必ず遅くなります。 保存の前に、読み込み側も見ておいてください。
CakePHPの公式ドキュメント(エンティティー)にも、こう書かれています。
例えば、複数の記事 (articles) とそれに属するコメント (comments) を舐めるループでは、 イテレートされた記事の数だけクエリーが何度も送られることになります。
| 症状 | 原因 | 対処 |
|---|---|---|
| 件数が増えると急に遅い | N+1(ループの中でクエリが飛ぶ) | 読み込みで contain() を使う |
| 100件で数十秒かかる | 1件ずつ save()+個別トランザクション | transactional() で1つにまとめる |
| メモリが足りなくなる | 全件を一度にEntity化している | 分割して処理する |
件数が数千を超えるなら、ORMを通さない選択も考えてください。 Entityの生成とバリデーションは1件ごとにコストがかかります。 取り込みバッチのように「速さが要件」の処理では、素のSQLで一括INSERTするほうが桁違いに速いことがあります。
ORMはアプリケーションのコードを読みやすくするための道具であって、 大量データの投入に最適化された道具ではありません。
2.x から移行するときの対応表
古いコードを引き継いだときに、まず当たるところをまとめます。
| 2.x | 3.x 以降 | 注意 |
|---|---|---|
$this->Model->create() | 相当するものは無い | Entityを毎回新しく作る |
$this->Model->save($data) | $table->save($entity) | 配列ではなくEntityを渡す |
saveAll() | saveMany() | アトミックではない |
$this->Model->read() | $table->get() / find() | 戻りがEntity |
$this->Model->validates() | $entity->getErrors() | エラーはEntityが持つ |
$this->loadModel('Posts') | $this->fetchTable('Posts') | 呼び方が変わった |
一番危ないのは saveAll() → saveMany() の置き換えです。
名前が似ているので機械的に置き換えがちですが、
トランザクションの挙動を確認しないまま移行すると、本番で初めて「途中まで入った」事故に気づきます。
移行時は「全部成功か全部失敗か」が要件かどうかを必ず確認し、
要件ならば transactional() で囲んでください。
AIでやるなら
CakePHPはバージョン間でAPIが大きく変わっているので、 AIに聞くと2.xと3.x以降が混ざった回答が返ってきやすい領域です。
バージョンを固定して聞く
プロンプト
CakePHP 5 で、配列から複数レコードをまとめて保存する処理を書いてください。
【環境】
- CakePHP 5
- 「全件成功するか、1件も保存されないか」が要件
【必ず守ること】
- saveMany() はエンティティーごとに個別トランザクションが走るので、
それだけでは要件を満たさない。transactional() で囲むこと
- Entity はループのたびに新しく作る(使い回さない)
- 失敗したエンティティーの getErrors() をログに出す
【書かないでほしいこと】
- CakePHP 2.x の書き方(create()、saveAll()、loadModel())
最後に、この処理が件数10万件になったときの問題点を挙げてください。
「2.xの書き方を出さないで」と明示してください。
create() や saveAll() はネット上の記事が多く、黙っていると混ざります。
saveMany のトランザクションの挙動も、こちらから伝えたほうが確実です。 AIは「saveManyはまとめて保存する」とだけ覚えていて、個別トランザクションであることを書き落とすことがあります。
出てきたコードの検証
| 見るところ | NGなら何が起きるか |
|---|---|
create() / saveAll() / loadModel() が無いか | 2.xの書き方。現行では動かない |
| Entityをループ外で作って使い回していないか | 2回目以降がUPDATEになる |
transactional() で囲んであるか | 途中失敗で中途半端なデータが残る |
エラーを getErrors() で取っているか | 失敗の理由が分からない |
動かす前にわざと失敗させてください。
| 試すこと | 確認できること |
|---|---|
| 途中の1件だけバリデーションに引っかかるデータを入れる | ロールバックされて0件になるか |
| 同じデータを2回流す | 重複登録されないか |
| 件数を10倍にする | 実行時間が線形か、急に悪化するか |
よくある質問
- ループの中で save() を呼んでも大丈夫ですか
- 動きます。ただし1件ごとに個別のトランザクションが走るので、件数が増えると遅くなります。また途中で失敗したときにそこまでの分が残ります。「全部か、ゼロか」が要件なら
transactional()で囲んでください。 - 2.x の create() に相当するものは何ですか
- ありません。3.x でORMが作り直され、状態がEntityに移ったためです。代わりにループのたびに
newEntity()で新しいEntityを作ってください。 - 2回目以降がUPDATEになってしまいます
- Entityを使い回していませんか。Entityは主キーを保持しているので、同じEntityに
patchEntity()で値を入れ替えながら保存すると、そのIDに対するUPDATEになります。毎回newEntity()で作り直してください。 - saveMany() を使えばトランザクションは勝手に効きますか
- 効きません。公式に「すべてのエンティティーの保存で個別のトランザクションが走ります」と書かれています。単一のトランザクションにしたいなら
transactional()を使ってください。 - save() が false を返しますが理由が分かりません
- Entityが持っています。
$entity->getErrors()でバリデーションエラーが取れます。save()の戻り値は成功時に変更されたエンティティー、失敗時にfalseです。 - saveAll() はどこへ行きましたか
- 3.x 以降は
saveMany()です。名前が似ていますがトランザクションの挙動を必ず確認してください。機械的に置き換えると、本番で「途中まで入った」事故になります。 - 10万件を入れたいのですが遅いです
- ORMは大量データの投入に最適化された道具ではありません。Entityの生成とバリデーションが1件ごとにかかります。速さが要件なら、素のSQLで一括INSERTするほうが桁違いに速いことがあります。分割処理とあわせて検討してください。
- CakePHP 2.x のまま使い続けても大丈夫ですか
- 2.x はEOLです。新規では使わないでください。既存のコードを引き継いだ場合は、対応表を見ながら移行を検討してください。
まとめ
CakePHP 2.x の罠は消えました。いまの罠は別のところにあります。
-
create()は要らなくなった。
3.x 以降は状態がEntityに移ったので、ループのたびにnewEntity()で作り直すだけです。 ただしEntityを使い回すと同じ事故が起きます。 -
saveMany()はアトミックではない。
公式に「すべてのエンティティーの保存で個別のトランザクションが走ります」とあります。 100件中50件目で失敗したら、49件は残ります。 -
「全部か、ゼロか」が要件なら
transactional()で囲む。
コールバックがfalseを返せばロールバック、例外ならロールバックして再スローです。
フレームワークのメジャーバージョンが変わったときは、
「動くかどうか」だけでなく「同じ意味のままか」を確認してください。
saveAll() と saveMany() は、名前は似ていても保証している範囲が違います。
PHPの学び方は PHPの勉強法、 外部URLからOGPを取る話は PHPでOGPを取得する に書きました。 どちらも「APIは変わっていないのに前提が変わった」という同じ型の話です。
公式情報は2026年10月25日時点で CakePHP 5.x の日本語ドキュメントから確認したものです。 挙動はバージョンで変わります。 使っているバージョンのドキュメントを必ず確認してください。