Laravelの更新処理はトランザクション境界で壊れやすい
Laravel トランザクションで実務上よく迷うのは、DB::transactionを書くかどうかではありません。どこからどこまでを同じトランザクションに入れるか。例外が起きたときに何をrollbackするか。外部API連携やジョブ投入をどのタイミングで実行するか。この境界設計で、更新系処理の安全性が大きく変わります。
例えば、決済、在庫引当、申請承認、ポイント付与、請求作成のような処理では、複数テーブルを更新します。そのため、一部だけ保存されると業務データが壊れます。一方で、メール送信や外部API呼び出しまで同じ感覚で扱うと、rollbackできない副作用が残ります。
結論から言うと、Laravelのトランザクション設計では、DB内で一貫性を守る処理と、外部へ副作用を出す処理を分けて考えます。DB更新は短いtransaction内に閉じます。そして、外部API連携やQueue投入はcommit後の実行、再試行、冪等性まで設計します。
Laravel トランザクションで守るべき範囲
まず、トランザクションで守るべき対象は「同時に成功するべきDB更新」です。Laravel公式のDatabase Transactionsでは、DBファサードのtransactionメソッドで一連のDB操作をまとめられることが説明されています。
ただし、DB::transactionは万能な業務補償ではありません。rollbackできるのは、基本的に同じDB接続上の変更です。外部APIに送ったリクエスト、送信済みメール、すでに実行されたジョブ、別システムに登録されたデータは、DBのrollbackだけでは元に戻りません。
| 処理 | transactionに入れる判断 | 実務上の注意点 |
|---|---|---|
| 注文、注文明細、在庫引当の保存 | 入れる | 一部だけ保存されると業務状態が壊れる |
| ステータス変更と履歴登録 | 入れる | 現在状態と履歴の不整合を避ける |
| 外部決済APIの呼び出し | 原則として境界を分ける | DB rollbackでは外部決済を取り消せない |
| メール送信、通知送信 | commit後に寄せる | rollback時に通知だけ届く事故を避ける |
| Queueのジョブ投入 | afterCommitを検討する | 未commitのデータをジョブが読まないようにする |
つまり、transactionの範囲は「不安だから広くする」ものではありません。むしろ、ロック時間と副作用を増やさないために、DB整合性に必要な範囲へ絞ります。
DB::transactionと例外処理の基本設計
Laravelでは、DB::transactionのクロージャ内で例外が投げられると、トランザクションはrollbackされます。そのため、Service層やApplication Service層で更新の単位を明確にし、Controllerには業務更新の細かい手順を置かない設計が扱いやすくなります。
<?php
namespace App\Services;
use App\Models\Order;
use App\Models\Product;
use Illuminate\Support\Facades\DB;
use DomainException;
class OrderService
{
public function confirmOrder(int $orderId): Order
{
return DB::transaction(function () use ($orderId) {
$order = Order::query()
->whereKey($orderId)
->lockForUpdate()
->firstOrFail();
if (! $order->canConfirm()) {
throw new DomainException('この注文は確定できません。');
}
foreach ($order->items as $item) {
$product = Product::query()
->whereKey($item->product_id)
->lockForUpdate()
->firstOrFail();
$product->decreaseStock($item->quantity);
$product->save();
}
$order->markAsConfirmed();
$order->save();
return $order;
}, attempts: 3);
}
}
この例では、注文確定と在庫更新を同じトランザクションに入れています。さらに、競合しやすい注文行と商品行はlockForUpdateで明示的にロックしています。もちろん、実際のロック戦略はDB製品、分離レベル、競合頻度によって変わります。
なお、Laravel公式ドキュメントでは、transactionメソッドの第2引数でデッドロック時の再試行回数を指定できることも説明されています。したがって、在庫や予約のように競合が起きやすい更新では、再試行してよい処理かどうかも設計に含めます。
例外を握りつぶすとrollbackされない
実務レビューでよく見る危険な書き方は、transaction内で例外をcatchして、そのまま正常終了してしまう形です。例外を握りつぶすと、呼び出し側は失敗を検知できません。また、意図したrollbackが起きない構造になります。
// 避けたい例
DB::transaction(function () use ($order) {
try {
$order->markAsPaid();
$order->save();
} catch (\Throwable $e) {
report($e);
// ここで終了すると、失敗が呼び出し側に伝わりにくい
}
});
ただし、catch自体が悪いわけではありません。ログを残したうえで再throwする、業務例外へ変換して投げ直す、またはtransaction外で例外をハンドリングする。こうした方針を明確にすれば、rollbackとAPIレスポンスの責務を分けられます。
外部API連携はLaravel トランザクションの外側で考える
次に、外部API連携です。決済API、配送API、会計API、CRM連携などは、Laravel側のDB transactionではrollbackできません。そのため、外部API呼び出しをtransaction内に入れると、DBだけ戻って外部側は進んでいる、または外部側だけ失敗してDBロックが長引く、という事故につながります。
そこで、外部連携は処理の性質で分けます。DB更新の前に外部認可が必要なのか。DB更新後に通知すればよいのか。失敗時に補償処理が必要なのか。この順序を曖昧にしないことが重要です。
| 連携パターン | 考え方 | 設計ポイント |
|---|---|---|
| 外部APIで事前確認する | transaction前に実行する | 結果を使ってDB更新する。長いDBロックを避ける |
| DB確定後に外部へ通知する | commit後に実行する | QueueやOutboxを検討する |
| 外部決済とDB更新をまたぐ | 補償処理を設計する | 取消API、状態管理、再実行可能性を持たせる |
| 同じ依頼が複数回来る | 冪等性を設計する | idempotency keyや一意制約を使う |
例えば、決済では「外部APIの成功」と「注文の支払済み反映」が別システムにまたがります。この場合、1つのDB transactionで全体を完全に守ろうとすると無理があります。現実的には、状態を細かく持ち、再実行や取消を設計します。
ジョブ投入はafterCommitと冪等性まで見る
LaravelでQueueを使う場合、transaction内でジョブをdispatchすると、workerがcommit前のデータを読もうとする可能性があります。Laravel公式のJobs & Database Transactionsでも、transaction内でdispatchされたジョブが親transactionのcommit前に処理される可能性と、after_commit設定やafterCommitメソッドが説明されています。
そのため、DB更新後にメール送信、通知、外部同期、集計更新などを行うなら、afterCommitを検討します。これにより、commit後にジョブを投入できます。rollbackされた場合に、不要なジョブが走るリスクも下げられます。
DB::transaction(function () use ($orderId) {
$order = Order::query()->findOrFail($orderId);
$order->markAsConfirmed();
$order->save();
SendOrderConfirmedMail::dispatch($order->id)->afterCommit();
});
ただし、afterCommitだけで十分とは限りません。ジョブは失敗後に再試行されます。また、workerの多重起動により、同じ処理が複数回走る可能性もあります。そこで、ジョブ側も冪等にします。
public function handle(): void
{
$order = Order::query()->findOrFail($this->orderId);
if ($order->mail_sent_at !== null) {
return;
}
// メール送信処理
$order->forceFill([
'mail_sent_at' => now(),
])->save();
}
このように、commit後に実行するだけでなく、再実行されても壊れない設計にします。特に外部API連携では、外部側のidempotency keyやLaravel側の一意制約もあわせて検討します。
更新系処理でレビューしたいLaravelトランザクション設計
実務レビューでは、コードが動くかどうかだけでは足りません。更新処理は、失敗時、同時実行時、再試行時にどう振る舞うかを確認します。特に、申請承認、在庫、決済、請求、ポイントのような処理では、この観点が重要です。
- transactionの範囲がDB整合性に必要な範囲へ絞られているか
- ControllerではなくService層などに更新単位がまとまっているか
- transaction内で外部API、メール送信、重い集計を実行していないか
- 例外をcatchしたあと、rollbackされる形で再throwしているか
- デッドロック時の再試行を入れてよい処理か判断しているか
- lockForUpdateを使う場合、ロック順序が揃っているか
- Queue投入はafterCommitまたはafter_commit設定を考慮しているか
- ジョブや外部連携が冪等に作られているか
- APIレスポンスと内部例外の変換が整理されているか
- テストで成功、失敗、rollback、二重実行を確認しているか
なお、LaravelのDBファサードが提供するtransaction関連メソッドの詳細は、Laravel API Documentationでも確認できます。公式ドキュメントと実装の前提を押さえると、レビュー時に「なぜその境界なのか」を説明しやすくなります。
よくある質問
LaravelのtransactionはControllerに書いてもよいですか?
小さな処理なら動きます。しかし、実務ではService層やApplication Service層に寄せる方が保守しやすいです。理由は、transaction境界が業務更新の単位と結びつくためです。Controllerに散らばると、同じ更新処理を再利用しにくくなります。
外部API呼び出しは必ずtransaction外に出すべきですか?
原則として、DBロックを長くしないために外側へ出します。ただし、業務上どうしても順序制御が必要な場合は、状態管理、補償処理、タイムアウト、再試行、冪等性をセットで設計します。単にtransaction内へ入れるだけでは、外部側の副作用はrollbackできません。
afterCommitを使えばジョブ投入は安全ですか?
commit前にジョブが走る問題は避けやすくなります。一方で、ジョブの失敗、再試行、二重実行までは自動で解決されません。そのため、ジョブ側の冪等性、処理済みフラグ、一意制約、外部APIのidempotency keyも確認します。
まとめ:Laravel トランザクション設計は副作用の境界を決める設計
Laravel トランザクション設計では、DB::transactionの書き方だけを覚えても不十分です。重要なのは、DB内で一貫性を守る範囲と、外部API連携やQueue投入のような副作用を分けることです。
まず、注文、在庫、申請、請求のような更新系処理では、同時に成功すべきDB更新をtransactionにまとめます。次に、例外処理では失敗を握りつぶさず、rollbackされる流れを保ちます。さらに、外部連携やジョブ投入はcommit後、再試行、冪等性まで設計します。
Laravel/SQL/業務系バックエンド案件では、こうした更新処理の設計を説明できるエンジニアが評価されやすいです。単にLaravelの機能を使えるだけでなく、失敗時や同時実行時の振る舞いまで考えられることが、実務での信頼につながります。
LaravelやSQLを使う案件で、更新系処理、トランザクション設計、外部API連携、Queue設計まで関われる環境を考えている場合は、現在の経験をもとに相談できます。
一度カジュアル面談をしませんか?
株式会社bluenaは「高還元」と「伴走支援」を両立したSES企業です。単価の81〜86%を還元する報酬体系と、専任サポーターによる隔週1on1で、エンジニアが納得できるキャリアを実現します。
まとまっていなくてもOK——まずは現在地を聞かせてください。
カジュアル面談ですので、お気軽にお聞かせください。





