A customer pays $49. Your Laravel app records the payment. The webhook response times out, so Stripe retries. Your app records another $49 payment.
Now your sales report shows $98, and the customer has two receipts. Stripe collected $49. Where did the extra $49 come from? Your webhook handler treated the retry as a new payment.
Use the payment ID as your business key
A Stripe event ID identifies a notification. A PaymentIntent ID identifies the payment this example records. You can log event IDs to spot retries, but the rule your database needs to enforce is one payment record per PaymentIntent. That also protects the handler if two different event IDs refer to the same successful payment. Stripe’s guide to duplicate webhook events
Make the database enforce it
This example assumes one Stripe account and one PaymentIntent per order. Save the PaymentIntent ID on the order when your server creates it, and add a unique index to orders.stripe_payment_intent_id and payments.stripe_payment_intent_id. The method below runs after your webhook endpoint verifies the signature and accepts a payment_intent.succeeded event.
use Illuminate\Support\Facades\DB;
final class RecordStripePayment
{
public function handle(string $intentId, int $amount, string $currency): void
{
DB::transaction(function () use ($intentId, $amount, $currency) {
$order = DB::table('orders')
->where('stripe_payment_intent_id', $intentId)
->lockForUpdate()
->first();
if (
$order === null ||
(int) $order->total_in_minor_units !== $amount ||
strtolower($order->currency) !== strtolower($currency)
) {
throw new DomainException('Payment does not match the order.');
}
if ($order->paid_at !== null) {
return;
}
$paidAt = now();
DB::table('payments')->insert([
'order_id' => $order->id,
'stripe_payment_intent_id' => $intentId,
'amount_in_minor_units' => $amount,
'currency' => strtolower($currency),
'created_at' => $paidAt,
]);
DB::table('orders')
->where('id', $order->id)
->update(['paid_at' => $paidAt]);
});
}
}
If two workers receive the same payment at once, the second waits for the order lock. After the first commits, the second sees paid_at and exits. If the first fails before committing, both database writes roll back, and a retry can process the payment. For effects outside the database, such as sending a receipt, use a durable job and a deduplication key tied to the payment. Laravel’s documentation on row locks
Test the retry
it('records a successful Stripe payment only once', function () {
$order = Order::factory()->create([
'stripe_payment_intent_id' => 'pi_example',
'total_in_minor_units' => 4900,
'currency' => 'usd',
'paid_at' => null,
]);
$handler = app(RecordStripePayment::class);
$handler->handle('pi_example', 4900, 'usd');
$handler->handle('pi_example', 4900, 'usd');
$this->assertDatabaseCount('payments', 1);
expect($order->fresh()->paid_at)->not->toBeNull();
});
Then deliver the same payment to two workers at the same time using the database engine you run in production. Check that the order ends up paid and exactly one payment row exists. Also try a mismatched amount and check that the order stays unpaid.
Working through a payment integration in an existing Laravel app? Tell LionCoders where it’s getting stuck.
Top comments (0)