Laravelでデータベースを操作する際、データの整合性を担保するために欠かせないのが「トランザクション(Transaction)」です。
「注文レコードを作成したのに、決済処理や在庫減算でエラーが発生し、注文だけが残ってしまった」「複数ユーザーからの同時購入で在庫数がマイナスになってしまった」といったデータ不整合のトラブルは、適切なトランザクション処理と排他制御を実装することで確実に防ぐことができます。
Laravelでは、クロージャを使って簡潔に書ける DB::transaction() による自動コミット・ロールバックと、細かい制御が可能な DB::beginTransaction() を用いた手動トランザクションの両方が用意されています。
この記事では、Laravelにおけるトランザクションの基本概念・2種類の実装手法・例外発生時のロールバック仕様・デッドロック時の自動リトライ設定(attempts引数)・DB::afterCommit() コールバック・絶対に避けるべきアンチパターンまで、実務ですぐに使えるコード例とともに徹底解説します。
- 【早見表】Laravel トランザクション実装手法の使い分け
- なぜトランザクションが必要なのか?(ACID特性とデータ不整合)
- 【基本】DB::transaction() の使い方と自動制御の仕組み
- 【手動制御】手動トランザクション(beginTransaction / commit / rollBack)の使い方
- 【応用】デッドロック対策と自動リトライ(第2引数 attempts)
- 【連携】lockForUpdate(排他ロック)との組み合わせ
- 【重要機能】DB::afterCommit() で安全な外部連携・メール送信
- 【落とし穴】トランザクションで絶対にやってはいけないアンチパターン4選
- 【テスト】PHPUnit / Pest でのトランザクションテスト
- まとめ:安全なトランザクション設計チェックリスト
- 関連記事
【早見表】Laravel トランザクション実装手法の使い分け
| 実装手法 | 特徴・コミット/ロールバック仕様 | 主なユースケース | コード例 |
|---|---|---|---|
DB::transaction()(自動制御) |
例外(Throwable)発生時に自動ロールバック、正常完了時に自動コミット。最も安全で推奨される標準手法。 |
通常のCRUD処理、複数テーブルの一括更新、注文・決済フローなど大半の処理 | DB::transaction(function () { ... }); |
DB::transaction($cb, $attempts)(デッドロック再試行) |
デッドロック(DeadlockException)発生時に、指定回数まで自動でリトライして再実行する。 |
アクセス集中時の在庫引き当て、残高送金、バッチ更新などロック競合が起きやすい処理 | DB::transaction(function () { ... }, 3); |
| 手動トランザクション ( beginTransaction / commit / rollBack) |
コミットとロールバックのタイミングを明示的に手動制御する。try-catch との組み合わせが必須。 |
外部API通信の結果に応じてロールバックしたい場合や、途中で例外を投げずに条件分岐で取り消したい場合 | DB::beginTransaction(); |
DB::afterCommit()(コミット後コールバック) |
トランザクションが正常にコミットされた後だけ指定した処理を実行する。 | 購入完了メール送信、外部Webhook通知、イベント発火、プッシュ通知など | DB::afterCommit(fn () => Mail::send(...)); |
なぜトランザクションが必要なのか?(ACID特性とデータ不整合)
Webアプリケーションでは、1つの業務処理で複数のデータベーステーブルを更新することが頻繁にあります。
例えば、ECサイトの「商品購入処理」では以下の一連の処理がすべて成功する必要があります。
ordersテーブルに注文履歴を作成するproductsテーブルの在庫数を減算するuser_pointsテーブルから消費ポイントを差し引く
もし 1 と 2 が成功した後に 3 でシステムエラーやネットワーク障害が発生した場合、トランザクションを使用していないと「注文と在庫減算は完了しているのに、ポイントは消費されていない」という致命的なデータの不整合が発生してしまいます。
トランザクションとは、複数のデータベース操作を「1つの不可分な作業単位(All or Nothing)」としてまとめる仕組みです。途中で1つでも失敗した場合はすべての変更を元の状態に戻し(ロールバック)、すべて成功した場合のみ確定(コミット)します。
【基本】DB::transaction() の使い方と自動制御の仕組み
Laravelで最も安全かつ標準的なトランザクションの書き方は、Illuminate\Support\Facades\DB ファサードの transaction() メソッドを使用する方法です。
① 基本構文と自動ロールバック仕様
DB::transaction() にクロージャ(無名関数)を渡すと、Laravelは以下の手順をすべて自動で行います。
- クロージャ開始時に自動でトランザクションを開始(
BEGIN) - クロージャ内で
ExceptionやError(\Throwable)が発生した場合、自動で変更を破棄(ROLLBACK)し、例外を再送出 - クロージャが例外を投げずに正常終了した場合、自動で変更を確定(
COMMIT)
use Illuminate\Support\Facades\DB;
use App\Models\Order;
use App\Models\Product;
use App\Models\UserPoint;
// DB::transaction による自動制御
$order = DB::transaction(function () use ($userId, $productId, $quantity, $pointsToUse) {
// 1. 在庫の確認と減算
$product = Product::findOrFail($productId);
if ($product->stock < $quantity) {
throw new \Exception('在庫が不足しています。');
}
$product->decrement('stock', $quantity);
// 2. 注文レコードの作成
$order = Order::create([
'user_id' => $userId,
'product_id' => $productId,
'quantity' => $quantity,
'total_price'=> $product->price * $quantity,
]);
// 3. ポイントの減算
if ($pointsToUse > 0) {
UserPoint::where('user_id', $userId)->decrement('points', $pointsToUse);
}
// 正常終了時はクロージャの戻り値を受け取れる
return $order;
});
② クロージャからの戻り値の受け取り
上記コードのように、クロージャ内で return $order; と返した値は、そのまま DB::transaction() の戻り値として変数に代入されます。トランザクション内で作成したIDやモデルインスタンスを後続の処理で利用したい場合に非常に便利です。
③ コントローラーでの例外ハンドリング
DB::transaction() の内部で例外がスローされると、自動ロールバックされた後に呼び出し元へ例外が伝播します。コントローラーやサービスクラスでは、外側を try-catch で囲んでユーザー向けのエラーメッセージを返却します。
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
public function checkout(Request $request)
{
try {
$order = DB::transaction(function () use ($request) {
// トランザクション処理
return $this->orderService->createOrder($request->all());
});
return response()->json([
'status' => 'success',
'message' => '注文が完了しました。',
'order' => $order,
], 201);
} catch (\Throwable $e) {
Log::error('購入処理に失敗しました: ' . $e->getMessage(), [
'user_id' => auth()->id(),
'trace' => $e->getTraceAsString(),
]);
return response()->json([
'status' => 'error',
'message' => '注文処理中にエラーが発生しました: ' . $e->getMessage(),
], 400);
}
}
【手動制御】手動トランザクション(beginTransaction / commit / rollBack)の使い方
DB::transaction() のクロージャ方式ではなく、開発者が明示的にトランザクションの開始・確定・取り消しを制御したい場合は、手動トランザクションメソッドを使用します。
① 手動トランザクションの3つのメソッド
DB::beginTransaction():トランザクションを開始するDB::commit():すべての変更を確定するDB::rollBack():トランザクション開始後のすべての変更を取り消す
② 手動トランザクションの基本テンプレート
手動トランザクションを実装する場合は、必ず try-catch 構文と組み合わせ、catch ブロック内で DB::rollBack() を呼び出す必要があります。
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
DB::beginTransaction();
try {
// 処理1: ユーザー情報の更新
$user->update(['status' => 'active']);
// 処理2: 関連プロフィールの作成
$user->profile()->create($profileData);
// 処理3: 外部決済APIなどの検証
$paymentSuccess = $this->paymentGateway->charge($user, 1000);
if (! $paymentSuccess) {
throw new \DomainException('決済承認が得られませんでした。');
}
// すべて成功したらコミット
DB::commit();
} catch (\Throwable $e) {
// 例外発生時は必ずロールバック
DB::rollBack();
Log::error('トランザクション異常終了: ' . $e->getMessage());
// 必要に応じて上位へ再スローまたはエラーハンドリング
throw $e;
}
PHP 7以降、重大な構文エラーや型エラーは
\Error クラスとしてスローされます。catch (\Exception $e) と書いていると、TypeError や DivisionByZeroError などのエラーを捕捉できず、ロールバックされないまま接続が残留する危険があります。必ず catch (\Throwable $e) を使用しましょう。
③ 自動と手動のどちらを選ぶべきか?
原則として「95%以上のケースでは DB::transaction() を使う」のがLaravelのベストプラクティスです。手動トランザクションは DB::commit() や DB::rollBack() の書き忘れによる接続ロック事故を起こしやすいため、以下のような特殊な要件がある場合のみ使用を検討してください。
- 例外を発生させず、条件分岐(
if-else)だけで明示的にロールバックを判定したいとき - トランザクションのスコープが複数メソッドに跨がり、クロージャで囲むのが構造上困難なとき
【応用】デッドロック対策と自動リトライ(第2引数 attempts)
① デッドロック(Deadlock)とは?
デッドロックとは、2つ以上のトランザクションが互いに相手の保持している行ロック(Row Lock)の解放を待ち合ってしまい、処理が永久に停止してしまう状態です。MySQL(InnoDB)やPostgreSQLはデッドロックを検知すると、一方のトランザクションを強制的にエラー(DeadlockException)として終了させます。
例えば、以下のような処理がほぼ同時に実行された場合に発生します。
- トランザクションA:ユーザー1を更新 ➔ ユーザー2を更新しようとする
- トランザクションB:ユーザー2を更新 ➔ ユーザー1を更新しようとする
② DB::transaction() の第2引数で自動再試行を設定
Laravelの DB::transaction() は、第2引数に最大試行回数(attempts)を渡すことができます。
use Illuminate\Support\Facades\DB;
// デッドロックが発生した場合、最大5回まで自動で再試行する
$result = DB::transaction(function () use ($fromUserId, $toUserId, $amount) {
$fromUser = User::lockForUpdate()->findOrFail($fromUserId);
$toUser = User::lockForUpdate()->findOrFail($toUserId);
$fromUser->decrement('balance', $amount);
$toUser->increment('balance', $amount);
return true;
}, 5); // 第2引数: 最大試行回数(デフォルトは 1)
Laravelは内部でデッドロック例外(MySQLエラー番号 1213 等)を検出すると、自動でロールバックを行い、ミリ秒単位のウェイトを挟んでクロージャ全体を最初から再実行します。指定回数試行してもデッドロックが解消しなかった場合のみ、例外がスローされます。
【連携】lockForUpdate(排他ロック)との組み合わせ
トランザクションを開始しただけでは、「他のトランザクションによる同じレコードの読み取り・変更」は防げません。
例えば、残高が1,000円のユーザーに対して「500円の引き落としリクエスト」が同時に2回届いた場合、両方のトランザクションが同時に「残高1,000円(500円以上ある)」と判定してしまい、最終残高が本来の0円ではなく500円になってしまうレースコンディション(競合状態)が起こります。
これを防ぐには、DB::transaction() 内で lockForUpdate()(悲観的排他ロック / SELECT ... FOR UPDATE)を併用します。
use Illuminate\Support\Facades\DB;
use App\Models\Wallet;
DB::transaction(function () use ($userId, $withdrawAmount) {
// lockForUpdate() で対象レコードを排他ロックし、他のトランザクションを待機させる
$wallet = Wallet::where('user_id', $userId)
->lockForUpdate()
->firstOrFail();
if ($wallet->balance < $withdrawAmount) {
throw new \Exception('残高が不足しています。');
}
$wallet->decrement('balance', $withdrawAmount);
});
排他ロックと共有ロック(sharedLock)の詳細な使い分けや注意点については、Laravel lockForUpdate()の使い方|排他ロック(悲観的ロック)とトランザクションの実装・注意点 で詳しく解説しています。
【重要機能】DB::afterCommit() で安全な外部連携・メール送信
トランザクション内でメール送信や外部API連携、キュージョブの発行を行うと、「DB処理が失敗してロールバックされたのに、購入完了メールだけが送信されてしまった」という深刻なバグ(幽霊通知)が発生します。
これを防ぐために、Laravelには DB::afterCommit() メソッドが用意されています。
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
use App\Mail\OrderCompletedMail;
DB::transaction(function () use ($user, $orderData) {
$order = Order::create($orderData);
// トランザクションが正常にコミットされた場合のみ実行される
DB::afterCommit(function () use ($user, $order) {
Mail::to($user->email)->send(new OrderCompletedMail($order));
});
// ここで例外が発生してロールバックされた場合、メール送信処理は破棄される
$this->paymentService->charge($order);
});
LaravelのイベントリスナーやMailableクラス、キュージョブでは、クラス内に
public $afterCommit = true; プロパティを宣言するか、Mail::to(...)->afterCommit() メソッドをチェーンするだけで、トランザクションのコミット完了を自動待機させることができます。
【落とし穴】トランザクションで絶対にやってはいけないアンチパターン4選
アンチパターン1:クロージャ内で例外をキャッチして握りつぶす
最もありがちなバグが、DB::transaction() のクロージャ内部で try-catch を行い、例外を再スローしないパターンです。
// ❌ 危険なコード:例外を握りつぶすと自動コミットされてしまう!
DB::transaction(function () {
User::create([...]);
try {
Order::create([...]); // ここでDBエラーが発生
} catch (\Exception $e) {
Log::error($e->getMessage());
// 例外を再スローしないと、Laravelは「正常終了」と判断してUser作成をコミットしてしまう!
}
});
// ⭕ 正しいコード:例外を再スローしてロールバックを発動させる
DB::transaction(function () {
User::create([...]);
try {
Order::create([...]);
} catch (\Exception $e) {
Log::error($e->getMessage());
throw $e; // 再スローして自動ロールバックさせる
}
});
アンチパターン2:トランザクション内に重い通信・外部APIを入れる
トランザクションを開いている間、データベースの接続コネクションが占有され、更新対象の行やテーブルにはロックがかかります。
外部APIの呼び出し(レスポンスに数秒かかる場合がある)やS3へのファイルアップロード、巨大CSVのパースなどをトランザクション内に入れると、データベース接続プールが即座に枯渇し、サイト全体が504タイムアウトに陥る原因になります。
- NG:トランザクション開始 ➔ 外部API通信(3秒)➔ DB更新 ➔ コミット
- OK:外部API通信(3秒)➔ トランザクション開始 ➔ DB更新(0.01秒)➔ コミット
アンチパターン3:ネストしたトランザクションとセーブポイントの誤解
Laravelでは DB::transaction() を入れ子(ネスト)にして記述することが可能です。
DB::transaction(function () {
// トランザクション レベル 1 (BEGIN)
User::create([...]);
DB::transaction(function () {
// トランザクション レベル 2 (SAVEPOINT trans2)
Profile::create([...]);
});
});
リレーショナルデータベース自体はネストしたトランザクションをネイティブサポートしていませんが、Laravelは内部でセーブポイント(Savepoint)を発行して擬似的にネストを処理します。現在のトランザクションの深さは DB::transactionLevel() で確認できます。
アンチパターン4:複数データベース接続(Multi DB)での勘違い
DB::transaction() は、デフォルトのDBコネクションに対してのみ作用します。複数のデータベース(例: mysql と pgsql)を使用している場合、デフォルト接続のトランザクションを開始しても、別接続のクエリはロールバックされません。
別接続でトランザクションを実行する場合は、DB::connection('other_connection')->transaction(...) のように明示的に接続名を指定する必要があります。
【テスト】PHPUnit / Pest でのトランザクションテスト
Laravelでデータベーステストを実行する際、テストごとにデータを自動でロールバックして初期状態に戻すためのトレイトが用意されています。
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Models\User;
class OrderTest extends TestCase
{
// 各テストケースをトランザクション内で実行し、終了時に自動ロールバックする
use RefreshDatabase;
public function test_purchase_process_updates_balance_correctly(): void
{
$user = User::factory()->create(['balance' => 1000]);
$response = $this->actingAs($user)->postJson('/api/orders', [
'amount' => 300,
]);
$response->assertStatus(201);
$this->assertDatabaseHas('users', [
'id' => $user->id,
'balance' => 700,
]);
}
}
RefreshDatabase トレイトを使用すると、テスト実行時にデータベースマイグレーションが適用され、各テストメソッドの実行がトランザクション内でラップされて自動ロールバックされるため、高速かつクリーンなテストが可能になります。
まとめ:安全なトランザクション設計チェックリスト
Laravelのトランザクションを安全かつ堅牢に運用するためのポイントをまとめました。
- 標準の処理:原則として
DB::transaction()のクロージャ自動制御を使用する - 高負荷・競合処理:第2引数に試行回数(
$attempts)を指定してデッドロックを自動再試行する - 競合状態の防止:残高更新や在庫引き当てには
lockForUpdate()を併用する - 外部連携・メール送信:ロールバック時の幽霊送信を防ぐため
DB::afterCommit()を活用する - 例外の握りつぶし禁止:クロージャ内部で
try-catchする場合は必ず例外を再スローする - 処理の極小化:外部APIリクエストや時間のかかる処理はトランザクションの外に出す
Laravelデータベース操作の関連記事
- Laravel lockForUpdate()の使い方|排他ロック(悲観的ロック)とトランザクションの実装・注意点
- Laravel Eloquentとは?使い方の基本からCRUD・リレーション・クエリビルダとの違いまで徹底解説
- LaravelのN+1問題を完全解決!with・loadによるEager Loadingと検知・防止テクニック
- Laravel Eloquentリレーション全種類まとめ|hasMany・belongsToの違いと正しい定義方法を徹底解説
- 【Laravel】Seeder完全ガイド|作成・実行・Factory連携から本番運用の注意点まで
- Laravel Migrationの使い方完全ガイド|作成・実行・ロールバックとよくあるエラー対処
DBトランザクションのコミット成功後に安全にジョブをディスパッチする設計パターンは、以下の記事で詳しく解説しています。
👉 Laravel キュー(Queue)と非同期処理の実装完全ガイド|database設定・Job作成・dispatchからワーカー常駐まで徹底解説

コメント