はじめに
前回の記事では、Laravel 環境で Stripe Checkout のテスト実装を行い、
実際に決済が完了するところまでを確認しました。
テストカードでの決済が成功し、
Stripe の管理画面にも売り上げが反映される。
さらに、Stripe CLI のログでも
「checkout.session.completed」が発生していることを確認できました。
ここでは、
- success_url が表示されること
- Stripe 側で売上が確定していること
- Webhook で「checkout.session.completed」が届いていること
これらをまとめて「Checkout Sessionの完了」としています。
ここまで動くと、一見すると実装は一段落したように感じます。
ただ、実際に手を動かしてみると、
「まだまだ本番までにすることがある」と感じる点がいくつも出てきました。
たとえば、
- 決済が完了したあと、どのタイミングでサービスの提供・在庫減などの業務処理を行うべきか
- 同じ決済通知が複数回届いた場合、処理はどうなるのか
- ローカル環境と本番環境で、Webhook の扱いはどう変わるのか
この記事では、前回確認した Webhook を起点として、
在庫を安全に更新するところまでを、
実装と検証の過程を交えながら整理していきます。
おまけとして、
Payment Links を使ったチップ機能のテスト実装についても触れます。
Checkout Sessionの完了だけでは、本番では使えない
決済後処理は Webhook を起点に設計する
前回は、テスト決済が完了し、Stripe 側で売上と Webhook イベントまで確認できました。
ここまで確認できると、
「Checkout Session は完了した」と判断できます。
実装は一段落したように感じていました。
ただ、実際には
Checkout Session の完了を確認できただけでは、
アプリケーションとしてはまだ本番では使えません。
理由はシンプルで、
本当に必要なのは
「決済が完了したこと」を起点として、何らかのサービス提供処理を確実に実行すること
だからです。
実際のサービスでは、決済完了後に
- 在庫を減らす
- 商品を提供する
- 機能や権限を開放する
といった業務上の処理が発生します。
このような処理には、大きく分けて2つのケースがあります。
- 商品在庫を持つケース
→ 決済完了後に在庫数を減らす - オンラインサービスやデジタルコンテンツのケース
→ 在庫を持たず、提供状態を切り替える
どちらの場合でも共通しているのは、
決済が「本当に完了した」ことを、サーバー側で確実に検知できる必要がある
という点です。
success_url はユーザーを画面遷移させるための仕組みであり、
決済処理の確定や、業務処理の実行を保証するものではありません。
そのため、
決済後の業務処理は画面遷移を起点にするのではなく、
Webhook によって送られてくる決済完了イベントを起点に設計する必要があります。
今回のテスト実装では、
動きが分かりやすい例として「在庫数を持つ商品」を対象にし、
Webhook を起点に在庫を更新する形で進めることにしました。
ローカル開発で躓いたポイント(Stripe CLI とルート設計)
前章では、
決済後の業務処理は画面遷移ではなく、
Webhook を起点に設計する必要がある、という方針を整理しました。
次に必要になるのは、
その設計が本当に正しく動くのかを、
ローカル環境でどのように検証するか、という点です。
前回の記事でも触れたとおり、
ローカル環境のままでは Stripe から直接 Webhook を受け取ることはできません。
そのため、Stripe CLI を使って
Webhook をローカルの Laravel アプリケーションに転送し、
実際の決済フローを再現しながら確認する必要があります。
この章では、
Webhook を起点とした処理をローカルで検証する過程で、
実際につまずいたポイントと、その原因・対処を整理していきます。
Stripe CLI は「起動しただけ」では足りなかった
最初に Stripe CLI を使ったときは、
Webhook secret(whsec_xxxxxxx)を取得し、
CLI を一度起動すれば準備は完了だと思っていました。
stripe listen --forward-to http://127.0.0.1:8000/<設定したエンドポイント>
出力例:
> Ready! You are using Stripe API Version [2025-11-17.clover].
Your webhook signing secret is whsec_xxxxxxxxxx
※ webhook signing secret(whsec_〜)は秘密情報です。
記事・SNS・GitHub 等に貼らないよう注意してください。
しかし実際には、
決済のテストを行う間、Stripe CLI は継続して起動している必要があります。
Webhook は、決済イベントが発生したタイミングで Stripe から送信されます。
そのため、決済を行った時点で CLI が起動していなければ、
Webhook はローカル環境には転送されません。
実際、CLI を止めた状態でテスト決済を行ったところ、
Stripe 側では決済が成功しているにもかかわらず、
Laravel 側では Webhook が一切届かない、という状態になりました。
決済の前後を通して、
Stripe CLI を稼働させ続ける必要がある。
これは、実際に試してみて改めて気づいたポイントでした。
Webhook が届かない原因は、ルートの配置にもあった
もう一つつまずいたのが、
Webhook のエンドポイントをどこに定義するか、という点です。
最初は通常の画面遷移と同じ感覚で、
Webhook の受信ルートを web.php に定義していました。
しかしこの状態では、Stripe CLI のログ上では Webhook が転送されているにもかかわらず、
Laravel 側では 404 エラーになっていました。

原因を追っていくと、
web.php に定義したルートには CSRF ミドルウェアが適用されており、
外部サービスからの POST リクエストをそのまま受け取れない、
という点に行き着きました。
Webhook のエンドポイントは api.php に置く
Webhook は、ユーザー操作ではなく、
外部サービスからサーバーに送信されるリクエストです。
そのため、CSRF 保護を前提とした web.php ではなく、
api.php にルートを定義する方が自然です。
Webhook のエンドポイントを api.php に移し、
Stripe CLI の転送先 URL もそれに合わせて修正したところ、
Laravel 側で正常に Webhook を受け取れるようになりました。

ローカル開発では、
Stripe CLI の稼働状態と、Webhook のルート設計の両方が正しく噛み合って、
初めて Webhook を起点とした処理を確認できるようになります。
Webhookを起点に在庫を更新する実装
ここまでで、ローカル環境でも Webhook を受け取れる状態が整いました。
この章では、Webhook を受け取ったあと、在庫更新の処理をどのように実装したのかを整理していきます。
今回のテスト実装では、在庫数を持つ商品を対象に、決済完了をトリガーとして在庫を減らす、というケースを扱っています。
実装にあたって意識したのは、Webhook が届いた時点で即座に処理を行うのではなく、
「どのイベントを対象に、どのタイミングで処理を行うか」を、あらかじめ明確にしておくことでした。
まずは、正しく一度だけ1つ在庫が減る状態を作ることをゴールとし、
在庫更新の処理はできるだけシンプルな形で実装しています。
処理対象とするイベントを限定する
Stripe の Webhook では、1回の決済に対して複数のイベントが送信されます。
たとえば、charge.succeeded や payment_intent.succeeded など、決済に関連するイベントが連続して発生します。
ただし、これらは「決済が最終的に完了した」ことを保証するものではありません。
今回の実装で出てきたイベント(ざっくり)
- payment_intent.created → 新規の支払い情報作成完了
- charge.succeeded → 請求成功
- payment_intent.succeeded → 支払い確定
- checkout.session.completed → Checkout Session 完了
- charge.updated → 請求情報更新
そのほかにもイベントは数多く存在します。
正式な情報は公式ドキュメント:Types of eventsを参照ください。
今回のプロジェクトでは、Webhook の処理起点として checkout.session.completed を採用しています。
これは「支払いの確定」そのものよりも、Checkout を通じた購入情報を、注文データとして正しく組み立てることを優先したためです。
payment_intent.succeeded はあくまで「支払いが成功した」という金銭的な結果を示すイベントです。
一方、checkout.session.completed では、購入者情報や metadata、PaymentIntent への参照など、注文に必要な情報をまとめて取得できます。
今回は Checkout で購入させる設計なので、Webhook で受け取るイベントは checkout.session.completed のみに限定しました。
Webhook受信後の処理の流れ
Webhook を受信したら、まずイベントの種類を判定し、
checkout.session.completed 以外のイベントは処理を行わずに終了します。
対象のイベントであることを確認できた場合のみ、在庫更新の処理へ進むようにしています。
public function handle(Request $request)
{
// ① 生のリクエストボディ(署名検証に必要)
$payload = $request->getContent();
// ② Stripeが付ける署名ヘッダー
$sigHeader = $request->header('Stripe-Signature');
// ③ Webhook secret(Stripe側で発行される)
$secret = config('services.stripe.webhook_secret');
try {
// ④ 署名検証(改ざんされたリクエストを弾く)
$event = Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (\Throwable $e) {
// 署名検証に失敗 → 400
return response('Invalid signature', 400);
}
// ⑤ イベント種別で分岐
// 1個だけ減らす処理
if ($event->type === 'checkout.session.completed') {
$session = $event->data->object;
// metadata に入れた product_id を取り出す
$productId = $session->metadata->product_id ?? null;
if ($productId) {
// ここでは常に 1個 減らす
$this->decrementStockOnceSafely((int) $productId, $event->id);
}
}
return response('ok', 200);
}
なぜ即在庫を減らさないのか
Webhook が届いたからといって、すぐに在庫を減らしてしまうのは安全とは言えません。
Stripe の Webhook は、通信状況などによって、同じイベントが再送されることがあります。
公式ドキュメント:イベント送信の動作
そのため、何も考えずに在庫更新を行うと、意図せず処理が重複してしまう可能性があります。
この点を考慮した上で、在庫更新の処理を設計する必要がありました。
同じWebhookが届いても、処理を一度だけにする
同じ Webhook が再送された場合でも、在庫更新の処理が一度だけ実行されるようにするために、今回のテスト実装で行った対策を整理します。
決済処理はアプリケーションの中でも影響範囲が大きいため、二重処理を防ぐ仕組みは欠かせません。
イベントIDを使って、処理済みかどうかを判定する
Stripe の Webhook には、それぞれのイベントに一意な event_id が付与されています。
この event_id を利用することで、「このイベントはすでに処理したかどうか」を判定できます。
今回の実装では、ローカル環境のDBにテーブルを作成し対応しました。
Webhook を受信したタイミングで event_id を保存し、すでに登録済みの場合は、在庫更新の処理を行わずに終了する仕様です。
これにより、同じ Webhook が再送された場合でも、在庫が二重に減ることを防げます。
/**在庫を1個だけ安全に減らす(マイナスにならないようガード、冪等性対応)**/
private function decrementStockOnceSafely(int $productId, string $eventId): void
{
DB::transaction(function () use ($productId, $eventId) {
// 冪等性(同じeventIdは二度処理しない)
if (DB::table('stripe_webhook_events')->where('event_id', $eventId)->exists()) {
\Log::info('Webhook ignored (already processed)', ['event_id' => $eventId]);
return; // すでに処理済み
}
// eventId を保存
DB::table('stripe_webhook_events')->insert([
'event_id' => $eventId,
'created_at' => now(),
'updated_at' => now(),
]);
// 在庫を 1 減らす(マイナスにならないようガード)
$product = Product::lockForUpdate()->findOrFail($productId);
if ($product->stock > 0) {
$product->decrement('stock', 1);
}
});
}
再送テストで、二重処理が起きないことを確認する
実装が正しく動いているかを確認するため、Stripe CLI を使って同じ Webhook イベントを再送するテストも行いました。
stripe events resend <event_id>
最初の Webhook では在庫が1つ減り、同じ event_id を再送した場合には、処理がスキップされ、在庫がそれ以上減らないことを確認できました。
Laravel のログ上でも、すでに処理済みであることを示すメッセージを出力することで、意図どおりの挙動になっているかを把握しやすくしています。
1個固定から、複数個決済に対応する(拡張を見据えた実装)
これまでの実装では、1回の決済につき商品を1個購入するケースを前提としていました。
まずは在庫更新の流れをシンプルに確認することを優先していたためです。
ただ、実際の利用を考えると、購入個数を選択できた方が使い勝手が良く、
一度の決済でまとめて購入できる方が、在庫管理の面でも自然な形になります。
そこで今回のテスト実装では、1個固定の決済から、複数個を選択して購入できる形へと拡張しました。
これにより、決済回数と在庫の増減を必ずしも1対1で考えなくてよくなり、より現実的な運用を想定できるようになります。
どこをどう変更したか(quantity を考慮した在庫更新)
複数個決済に対応するために行った変更は、実装全体から見るとごく一部です。
これまでの処理では「決済が完了したら在庫を1減らす」という前提で、在庫更新のロジックを組んでいました。
複数個対応では、この部分を「購入された個数分だけ在庫を減らす」形に調整しています。
// ⑤ イベント種別で分岐
// N個減らす処理
if ($event->type === 'checkout.session.completed') {
$session = $event->data->object;
$productId = $session->metadata->product_id ?? null;
if (!$productId) return response('ok', 200);
$stripe = new StripeClient(config('services.stripe.secret'));
// line_items を取得(複数商品の場合に対応するため)
$lineItems = $stripe->checkout->sessions->allLineItems($session->id, [
'limit' => 100,
]);
$quantity = 0;
foreach ($lineItems->data as $item) {
$quantity += (int) ($item->quantity ?? 0);
}
// 0なら何もしない
if ($quantity > 0) {
$this->decrementStockSafely((int)$productId, $quantity, $event->id);
}
}
return response('ok', 200);
/**在庫を指定数だけ安全に減らす(マイナスにならないようガード、冪等性対応)**/
private function decrementStockSafely(int $productId, int $qty, string $eventId): void
{
DB::transaction(function () use ($productId, $qty, $eventId) {
// 冪等性(同じeventIdは二度処理しない)
// eventId を保存
// 在庫を 1 減らす(マイナスにならないようガード)
// 変更点:在庫が足りない場合は0までにする、または例外にする(今回は0までが無難)
$decrement = min($qty, $product->stock);
if ($decrement > 0) {
$product->decrement('stock', $decrement);
}
});
}
具体的には、Checkout Session から取得できる購入数量(quantity)を使い、在庫更新時にその値を反映するようにしました。
Webhook の受信やイベント判定の流れ自体は変更せず、在庫を更新する箇所だけを拡張しています。
そのため、1個購入の場合も複数個購入の場合も、同じ処理フローのまま対応できるようになりました。
複数個対応でも、二重処理は起きないか
複数個対応にした場合でも、Webhook が再送される可能性がある点は変わりません。
そのため、event_id を使って処理済みかどうかを判定する仕組みは、そのまま維持しています。
同じイベントが再送された場合でも、在庫が二重に減ることはありません。
1個固定の場合と同様に、複数個決済でも一度だけ処理されることを、Stripe CLI を使って再送テストで確認しています。
今後、価格や商品情報を柔軟に管理する場合
今回の実装では、購入個数を quantity として扱うことで、1個固定から複数個決済に対応しました。
Stripe Checkout では、このように一部の値を差し替えるだけで、挙動を段階的に拡張できるポイントがいくつかあります。
ここでは、今後の拡張を考える際に押さえておきたい項目を整理します。
拡張時に変更する主なポイント
- quantity
購入個数を制御する値です。固定値にすると1個固定の決済になり、変数として扱うことで複数個選択に対応できます。
※今回の実装で変更したポイントです。 - unit_amount / price
商品の価格を表す値です。現在はコード側で指定していますが、Stripe の商品カタログ(Price)を使う場合は Price ID を指定する形に変わります。
これにより、価格管理を Stripe 側に寄せられます。 - line_items
購入内容のまとまりを表します。単一商品の決済ではシンプルですが、複数商品を扱う場合や構成を拡張する場合の起点になります。 - metadata
決済とアプリケーション側のデータを紐づけるための情報です。商品IDや注文IDを持たせておくことで、Webhook 受信後の処理を柔軟に設計できます。 - success_url / cancel_url
ユーザー体験に関わる部分です。業務処理の起点にはしませんが、決済後の導線を調整したい場合に変更します。
変わらない設計のポイント
- Webhook を起点に処理を行うという考え方
- event_id を使って処理を一度だけに制御する仕組み
数量や価格、商品構成が変わっても、この設計自体はそのまま使い続けられます。
今回の実装は、こうした項目を後から差し替えられる形にしておくことで、段階的に拡張できる状態を目指したものになっています。
おまけ:Payment Links を使った、シンプルなチップ実装
ここまでの記事では、Stripe Checkout を使い、
Webhook を起点とした在庫更新や複数個決済の実装を整理してきました。
一方で、
- 在庫管理や細かな制御までは必要ない
- とにかくシンプルに決済を受け付けたい
というケースもあります。
そうした場合の選択肢として、Stripe が提供している Payment Links があります。
Payment Links とは
Payment Links は、Stripe 側で用意された決済ページを使い、リンクを貼るだけで決済を行える仕組みです。
アプリケーション側で Checkout Session を生成したり、Webhook を受け取って処理を分岐したりする必要がなく、非常に手軽に決済を組み込めます。
チップ・寄付に関する Stripe の公式な考え方
Stripe の公式ドキュメントでは、チップや寄付の扱いについて、次のように説明されています。
Stripe Payments でチップや寄付を受け付ける場合、
チップは「提供した商品やサービスに対する支払い」である必要があり、
寄付は「特定の慈善目的に関連付けられている必要がある」とされています。
また、Stripe は個人への寄付や、ピアツーピアでの送金には対応していません。
チップや寄付を受け付ける場合には、各地域の規制や、国ごとの送金に関する法律を遵守する必要があります。
公式ドキュメント:チップや寄付の受け付けに関する要件
今回のケースでの位置づけ
今回の記事で想定している Payment Links の使い方は、慈善目的としての「寄付」ではなく、
記事や情報提供といったサービスに対するチップとしての支払いです。
あくまで、何らかのコンテンツやサービスを提供した上で、その対価や応援の気持ちとして支払ってもらう、という位置づけになります。
この前提を踏まえた上で、実際には Stripe の Payment Links を使い、チップ用の決済リンクを作成しました。
*現在はテストモード(サンドボックス版)です。
今回はテストモードでの実装のため、テストカードでのテストのみ行えます。
もし実際のカードで処理してしまった場合は、手数料等発生する可能性があるためお気をつけください。
- 4242 4242 4242 4242 : 成功するテストカード
- 4000 0025 0000 3155 : 認証(3Dセキュア)が必要なテストカード
- 4000 0000 0000 9995 : 拒否されるテストカード
Payment Links 実装手順
- Stripe アカウントの設定
- Payment Links の新規作成
- 支払い確認ページ・支払い完了ページの作成
- タイプを選択(必須:商品またはサブスクリプション / 顧客が支払い金額を選択する)
- 商品 / タイトルの設定 など
プレビュー画面を参考にしながら商品の登録
- 「リンクを作成」ボタンからリンク作成
- リンクをコピー、もしくは「購入ボタン」からコードをコピー
- リンクを貼りたい場所に貼り付け
以上で終了です。
※ 注意
実際のカードでテストを行うと、手数料が発生する可能性があるため十分に注意してください。
本番環境の設定には、ビジネス情報や個人情報の登録、税務処理の登録など、
さまざま手順が発生します。
*実際の本番環境で公開したかったのですが、手続き処理・認証等に時間がかかっているため、今回はテスト環境での公開となりました。
おわりに:決済を「動かす」から「使える形」にするまで
この記事では、Stripe Checkout のテスト実装を出発点として、Webhook を起点にした在庫更新処理を、段階的に整理・実装してきました。
前回の記事では、決済が完了し、Webhook が届くところまでを確認しましたが、
本記事ではその先として「届いた Webhook をどう扱うか」「業務処理としてどう成立させるか」という部分に焦点を当てています。
checkout.session.completed のイベントを起点に在庫を更新し、
Webhook が再送された場合でも処理が一度だけ実行されるように制御する。
1個固定の決済から複数個選択の決済へと拡張することで、
実際の運用を想定した形に近づけることができました。
実装自体は小さな変更の積み重ねですが、
「どこを起点に処理するか」
「どの値を後から変えられるようにしておくか」
といった設計の判断が、後々の拡張や運用のしやすさに大きく影響することを、改めて実感しました。
今回まとめた内容が、Stripe を使った決済実装を考える際に、一つの整理材料として役立てば幸いです。
次のステップとしては、価格や商品情報をアプリ側で持つのではなく、Stripe の商品カタログ(Product / Price)と連携し、管理を Stripe 側に寄せる方法も検討できます。
また、在庫管理や細かな制御が不要なケースでは、
前の章で触れた Payment Links のように、
よりシンプルな決済方法も選択可能です。
※今回のStripe実装について、
実装中に考えていたことや試行錯誤の流れを、
noteの方にも記録として残しています。
少し違った視点になりますが、よければこちらもどうぞ。
→ あさき|学びを言語化するnote更新中
「LaravelでStripe Checkoutを実装した記録②」
この記事はいかがでしたか?
本記事が参考になったり、今後の活動を応援していただける方は、
以下からARSYAへのチップを受け付けております。(こちらは本番リンク)
応援いただけると幸いです。


コメント