Building a Production-Grade E-Commerce Platform with Laravel 12 - Part 3: Stripe Payments, Webhooks & Order Lifecycle - NextGenBeing
Back to discoveries
Part 3 of 8

Building a Production-Grade E-Commerce Platform with Laravel 12 - Part 3: Stripe Payments, Webhooks & Order Lifecycle

Laravel 12 Stripe webhooks that handle duplicate and out-of-order events: signature check, idempotency keys, an order state machine, and a full runnable test setup.

Comprehensive Tutorials 12 min read
Bekzod Erkinov

Bekzod Erkinov

May 12, 2026 • 124 views
Building a Production-Grade E-Commerce Platform with Laravel 12 - Part 3: Stripe Payments, Webhooks & Order Lifecycle
Size:
Height:
12 min read 4,761 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
Table of contents · 9 sections

Building a Production-Grade E-Commerce Platform with Laravel 12 - Part 3: Stripe Payments, Webhooks & Order Lifecycle

Most Laravel Stripe webhook tutorials stop at "verify the signature and listen for payment_intent.succeeded". That leaves the parts that bite: duplicate deliveries, events arriving out of order, and an order status that can be flipped to anything. By the end you'll have a PaymentIntent service with idempotency keys, a signature-verified webhook endpoint, and an order state machine, and every file needed to run it without a Stripe account.

What was and wasn't run. Everything below ran against stripe-mock and webhooks I signed myself. None of it ran against live Stripe, and stripe-mock can't prove some things (see "What the mock can't tell you"). I read the Stripe docs on 2026-10-08. Versions, from real output:

$ docker run --rm stripe/stripe-mock:v0.206.0 --version
stripe-mock 0.206.0
$ docker run --rm composer:2 php -v | head -1
PHP 8.5.11 (cli) (built: Sep 24 2026 19:10:01) (NTS)

composer.lock in the project pins laravel/framework v12.69.3 and stripe/stripe-php v22.0.0.

This is Part 3 of 8. It builds on Part 1 and Part 2, but you don't need them. Webhook routes are also an attack surface; see Security Hardening a Laravel App for the wider checklist.

Set up Laravel 12 with the Stripe SDK and stripe-mock

A fresh Laravel 12 app, the Stripe SDK, and SQLite is all this part needs. The orders table is a stand-in for the one from Part 2.

docker run --rm -v "$PWD:/w" -w /w composer:2 create-project laravel/laravel:^12.0 shop
cd shop && docker run --rm -v "$PWD:/w" -w /w composer:2 require stripe/stripe-php
docker network create shopnet
docker run -d --name sm --network shopnet stripe/stripe-mock:v0.206.0

Add to .env:

STRIPE_SECRET=sk_test_123
STRIPE_API_BASE=http://sm:12111
STRIPE_WEBHOOK_SECRET=whsec_demo

In config/services.php:

'stripe' => [
    'secret' => env('STRIPE_SECRET'),
    'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
    'api_base' => env('STRIPE_API_BASE', 'https://api.stripe.com'),
],

Bind a client in AppServiceProvider::register(). api_base is how you point stripe-php at the mock; in production leave it at the default.

$this->app->singleton(\Stripe\StripeClient::class, fn () => new \Stripe\StripeClient([
    'api_key' => config('services.stripe.secret'),
    'api_base' => config('services.stripe.api_base'),
]));

Migration database/migrations/2026_10_08_000001_create_orders_tables.php, then php artisan migrate:

public function up(): void
{
    Schema::create('orders', function (Blueprint $t) {
        $t->id();
        $t->string('status')->default('pending');
        $t->unsignedInteger('amount');          // minor units (cents)
        $t->string('currency', 3)->default('usd');
        $t->string('payment_intent_id')->nullable()->unique();
        $t->unsignedInteger('payment_attempt')->default(1);
        $t->timestamps();
    });

    Schema::create('stripe_events', function (Blueprint $t) {
        $t->string('id')->primary();            // evt_...
        $t->string('type');
        $t->timestamp('received_at')->useCurrent();
    });
}

Route, in routes/web.php:

Route::post('/stripe/webhook', \App\Http\Controllers\StripeWebhookController::class);

PaymentIntents or Checkout?

Stripe's lifecycle page says: "Stripe recommends using the Checkout Sessions API with the Payment Element over Payment Intents for most integrations", and that the Payment Intent API "requires significantly more code". My rule:

  • Hosted or embedded Checkout if you sell fixed carts and don't need a custom payment form. Less code, fewer states to own.
  • PaymentIntents if you already have your own order model and totals (as this series does) and want the order to be the source of truth.
  • Cashier or the Spatie package if you want signature checks and logging handled for you. Spatie's package verifies signatures and stores calls in a webhook_calls table, but the business logic is still yours. I wrote the endpoint directly so every step is visible.

Model an order state machine in Laravel with a PHP enum

The PaymentIntent statuses (requires_payment_method, requires_action, processing, succeeded, canceled) are Stripe's; your order has its own.

enum OrderStatus: string
{
    case Pending = 'pending';
    case AwaitingPayment = 'awaiting_payment';
    case Paid = 'paid';
    case PaymentFailed = 'payment_failed';
    case Refunded = 'refunded';
    case Canceled = 'canceled';

    public function allowedNext(): array
    {
        return match ($this) {
            self::Pending => [self::AwaitingPayment, self::Canceled],
            self::AwaitingPayment => [self::Paid, self::PaymentFailed, self::Canceled],
            self::PaymentFailed => [self::AwaitingPayment, self::Paid, self::Canceled],
            self::Paid => [self::Refunded],
            self::Refunded, self::Canceled => [],
        };
    }

    public function canMoveTo(self $next): bool
    {
        return in_array($next, $this->allowedNext(), true);
    }
}

The model:

class Order extends Model
{
    protected $guarded = [];
    protected $attributes = ['status' => 'pending', 'payment_attempt' => 1];

    protected function casts(): array
    {
        return ['status' => OrderStatus::class];
    }

    public function transitionTo(OrderStatus $next): void
    {
        if (! $this->status->canMoveTo($next)) {
            throw new RuntimeException("Illegal order transition {$this->status->value} -> {$next->value}");
        }
        $this->update(['status' => $next]);
    }
}

PaymentFailed -> Paid is allowed on purpose: the lifecycle page says a failed attempt returns the intent to requires_payment_method so the payment can be retried.

The $attributes line is a gotcha that cost me a run. After Order::create(['amount' => 4999]), status is null in memory, because column defaults aren't hydrated. My first call died with Call to a member function canMoveTo() on null.

Create a Stripe PaymentIntent with an idempotency key in Laravel

class PaymentService
{
    public function __construct(private StripeClient $stripe) {}

    public function startPayment(Order $order): string
    {
        $intent = $this->stripe->paymentIntents->create([
            'amount' => $order->amount,          // minor units
            'currency' => $order->currency,
            'automatic_payment_methods' => ['enabled' => true],
            'metadata' => ['order_id' => (string) $order->id],
        ], [
            // Same order + same attempt => same key
            'idempotency_key' => "order-{$order->id}-attempt-{$order->payment_attempt}",
        ]);

        // One transaction: the intent id and Pending -> AwaitingPayment land together.
        DB::transaction(function () use ($order, $intent) {
            $order->payment_intent_id = $intent->id;
            $order->save();
            if ($order->status !== OrderStatus::AwaitingPayment) {
                $order->transitionTo(OrderStatus::AwaitingPayment);
            }
        });

        return $intent->client_secret;
    }
}

Per Stripe's docs, the server saves the status code and body of the first request for a key, errors if a retry's parameters differ, and may prune keys after 24 hours. Keys can be up to 255 characters. Derive the key from something stable, not a fresh UUID per call, or a network retry makes a second PaymentIntent. The attempt counter means bumping it (after a cancel, say) deliberately yields a new intent.

If the app crashes between the Stripe call and the save, Stripe has an intent but your order is still pending with no payment_intent_id. Re-running startPayment() sends the same key, so (per the docs, not observed here) Stripe replays the first response and you save the same intent. A fresh UUID key would create a second intent instead. If payment_intent.succeeded arrives in that window, the webhook below answers 503 and Stripe retries it.

What my run showed

I put a small logging proxy (proxy.py, below) between the app and stripe-mock and called startPayment() twice for one order via php artisan tinker. The proxy printed:

POST /v1/payment_intents Idempotency-Key=order-1-attempt-1
POST /v1/payment_intents Idempotency-Key=order-1-attempt-1

That shows the header is sent and stable. It does not show Stripe deduping: stripe-mock is stateless and returns hardcoded responses, and in my run the two calls returned different PaymentIntent ids (the order ended up holding the second one).

Test idempotency for real (not run)

I had no test-mode key, so this recipe is not run. With a test key, send the same request twice:

KEY=order-1-attempt-1
for i in 1 2; do
  curl -s https://api.stripe.com/v1/payment_intents -u "$STRIPE_TEST_KEY:" \
    -H "Idempotency-Key: $KEY" -d amount=4999 -d currency=usd | grep '"id"'
done
# changed params, same key:
curl -s https://api.stripe.com/v1/payment_intents -u "$STRIPE_TEST_KEY:" \
  -H "Idempotency-Key: $KEY" -d amount=5000 -d currency=usd

What the docs lead me to expect: both loop iterations print the same pi_ id, and the last call returns an error because the parameters differ from the first use of the key. Check the exact error type yourself.

Verify Stripe webhook signatures in Laravel

Four things have to be right: CSRF exemption, raw body, signature check, dedupe.

CSRF. Laravel's docs use Stripe as the example. In bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->validateCsrfTokens(except: ['stripe/webhook']);
})

The same docs say CSRF is disabled automatically when running tests, so a feature test can't tell you this line is missing. Only the real HTTP run below does.

Raw body and signature. Stripe's webhook docs say any manipulation of the raw body breaks verification. $request->getContent() gives you the untouched string. The header is t=<timestamp>,v1=<hmac>, where the HMAC-SHA256 (keyed with the endpoint secret) covers "{timestamp}.{body}". The libraries' default tolerance is 5 minutes, and the docs warn that a tolerance of 0 disables the recency check.

Dedupe and ordering. The docs say Stripe doesn't guarantee order, may deliver an event more than once, and that you should track event IDs rather than compare created. So the event id is a primary key, and the insert and the state change share one transaction:

public function __invoke(Request $request)
{
    try {
        $event = Webhook::constructEvent(
            $request->getContent(),
            (string) $request->header('Stripe-Signature'),
            config('services.stripe.webhook_secret'),
        );
    } catch (UnexpectedValueException | SignatureVerificationException $e) {
        return response($e->getMessage(), 400);
    }

    DB::transaction(function () use ($event) {
        $fresh = DB::table('stripe_events')->insertOrIgnore([
            'id' => $event->id, 'type' => $event->type,
        ]);
        if (! $fresh) return;                       // redelivery

        $target = match ($event->type) {
            'payment_intent.succeeded' => OrderStatus::Paid,
            'payment_intent.payment_failed' => OrderStatus::PaymentFailed,
            default => null,
        };
        if (! $target) return;

        $order = Order::where('payment_intent_id', $event->data->object->id)
            ->lockForUpdate()->first();
        if (! $order) {
            // Not saved yet (see startPayment) or not ours. Aborting inside the
            // transaction rolls the dedupe row back, so Stripe's retry is processed.
            abort(503, 'unknown PaymentIntent, retry later');
        }

        try {
            $order->transitionTo($target);
        } catch (RuntimeException $e) {
            Log::info('ignored out-of-order event', ['event' => $event->id, 'why' => $e->getMessage()]);
        }
    });

    return response()->noContent();
}

The transaction matters in two places. If transitionTo hits a database error, or the PaymentIntent is unknown, the dedupe row rolls back and the 5xx response lets the retry through instead of having it skipped as a "duplicate". My first version logged a warning and returned for unknown intents; the dedupe row had already been inserted, so that event would have been lost for good. The new test test_unknown_payment_intent_is_retried_not_lost covers it.

Stripe's webhook docs say: "Stripe attempts to deliver events to your destination for up to three days with an exponential back off in live mode", and "We retry event deliveries created in a sandbox three times over the course of a few hours" (see Automatic retries).

I run the work inline to keep the sample short. The docs recommend an asynchronous queue and returning 2xx before slow work. When your handler grows past a state update (emails, inventory, ERP calls), dispatch a job after the dedupe insert.

Test Laravel Stripe webhooks locally with signed payloads

Feature tests. tests/Feature/StripeFlowTest.php, with the signing helper in deliver():

class StripeFlowTest extends TestCase
{
    use RefreshDatabase;

    private string $secret = 'whsec_test_local_secret';

    protected function setUp(): void
    {
        parent::setUp();
        config(['services.stripe.webhook_secret' => $this->secret]);
    }

    private function deliver(array $event, ?int $ts = null, ?string $secret = null)
    {
        $body = json_encode($event);
        $ts ??= time();
        $sig = hash_hmac('sha256', "{$ts}.{$body}", $secret ?? $this->secret);

        return $this->call('POST', '/stripe/webhook', [], [], [], [
            'HTTP_STRIPE_SIGNATURE' => "t={$ts},v1={$sig}",
            'CONTENT_TYPE' => 'application/json',
        ], $body);
    }

    private function evt(string $id, string $type, string $pi): array
    {
        return ['id' => $id, 'object' => 'event', 'type' => $type,
            'data' => ['object' => ['id' => $pi, 'object' => 'payment_intent']]];
    }

    private function order(OrderStatus $s = OrderStatus::AwaitingPayment): Order
    {
        return Order::create(['status' => $s, 'amount' => 4999, 'payment_intent_id' => 'pi_123']);
    }

    public function test_valid_signature_marks_order_paid(): void
    {
        $o = $this->order();
        $this->deliver($this->evt('evt_1', 'payment_intent.succeeded', 'pi_123'))->assertNoContent();
        $this->assertSame(OrderStatus::Paid, $o->fresh()->status);
    }

    public function test_bad_signature_is_rejected_and_changes_nothing(): void
    {
        $o = $this->order();
        $this->deliver($this->evt('evt_1', 'payment_intent.succeeded', 'pi_123'), secret: 'whsec_wrong')->assertStatus(400);
        $this->assertSame(OrderStatus::AwaitingPayment, $o->fresh()->status);
        $this->assertDatabaseCount('stripe_events', 0);
    }

    public function test_stale_timestamp_is_rejected(): void
    {
        $this->order();
        $this->deliver($this->evt('evt_1', 'payment_intent.succeeded', 'pi_123'), ts: time() - 600)->assertStatus(400);
    }

    public function test_redelivery_is_processed_once(): void
    {
        $o = $this->order();
        $e = $this->evt('evt_1', 'payment_intent.succeeded', 'pi_123');
        $this->deliver($e)->assertNoContent();
        $o->update(['status' => OrderStatus::Refunded]); // simulate later state change
        $this->deliver($e)->assertNoContent();           // same evt_ id again
        $this->assertSame(OrderStatus::Refunded, $o->fresh()->status);
        $this->assertDatabaseCount('stripe_events', 1);
    }

    public function test_late_failure_after_paid_is_ignored(): void
    {
        $o = $this->order();
        $this->deliver($this->evt('evt_2', 'payment_intent.succeeded', 'pi_123'));
        $this->deliver($this->evt('evt_1', 'payment_intent.payment_failed', 'pi_123'))->assertNoContent();
        $this->assertSame(OrderStatus::Paid, $o->fresh()->status);
    }

    public function test_illegal_transition_throws(): void
    {
        $o = $this->order(OrderStatus::Paid);
        $this->expectExceptionMessage('Illegal order transition paid -> awaiting_payment');
        $o->transitionTo(OrderStatus::AwaitingPayment);
    }

    public function test_unknown_payment_intent_is_retried_not_lost(): void
    {
        $e = $this->evt('evt_9', 'payment_intent.succeeded', 'pi_late');
        $this->deliver($e)->assertStatus(503);
        $this->assertDatabaseCount('stripe_events', 0);   // dedupe row rolled back

        $o = Order::create(['status' => OrderStatus::AwaitingPayment, 'amount' => 4999, 'payment_intent_id' => 'pi_late']);
        $this->deliver($e)->assertNoContent();            // Stripe's retry
        $this->assertSame(OrderStatus::Paid, $o->fresh()->status);
    }
}

Output of php artisan test --filter=StripeFlowTest after the 503 change:

   PASS  Tests\Feature\StripeFlowTest
  ✓ valid signature marks order paid
  ✓ bad signature is rejected and changes nothing
  ✓ stale timestamp is rejected
  ✓ redelivery is processed once
  ✓ late failure after paid is ignored
  ✓ illegal transition throws
  ✓ unknown payment intent is retried not lost

  Tests:    7 passed (22 assertions)

Real HTTP, CSRF active. I served the app with php artisan serve --port=18123 and used two helpers. The logging proxy, proxy.py (point STRIPE_API_BASE at it; it forwards to the mock and prints each request's idempotency key):

import http.server, urllib.request

UP = "http://sm:12111"

class H(http.server.BaseHTTPRequestHandler):
    def do_POST(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        print(f"{self.command} {self.path} Idempotency-Key={self.headers.get('Idempotency-Key')}", flush=True)
        req = urllib.request.Request(UP + self.path, data=body, method="POST",
            headers={k: v for k, v in self.headers.items() if k.lower() in ("authorization", "content-type")})
        try:
            r = urllib.request.urlopen(req)
        except urllib.error.HTTPError as e:
            r = e
        data = r.read()
        self.send_response(r.status if hasattr(r, "status") else r.code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(data)))
        self.end_headers()
        self.wfile.write(data)

    def log_message(self, *a):
        pass

http.server.HTTPServer(("0.0.0.0", 8080), H).serve_forever()

And send.sh, which builds the signature exactly as described above (usage: send.sh <event_id> <type> <pi> [secret] [age_seconds]):

#!/bin/bash
ID=$1; TYPE=$2; PI=$3; SECRET=${4:-whsec_demo}; AGE=${5:-0}
BODY="{\"id\":\"$ID\",\"object\":\"event\",\"type\":\"$TYPE\",\"data\":{\"object\":{\"id\":\"$PI\",\"object\":\"payment_intent\"}}}"
TS=$(( $(date +%s) - AGE ))
SIG=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}')
curl -s -w ' -> %{http_code}\n' -X POST http://localhost:18123/stripe/webhook \
  -H "Content-Type: application/json" -H "Stripe-Signature: t=$TS,v1=$SIG" --data-binary "$BODY"

I created an order, called startPayment() (which saved pi_RUm8mbM2omhDa7p), then ran send.sh for each line below. Output, trimmed:

evt_a1 succeeded   secret=wrong        -> 400  No signatures found matching the expected signature for payload
evt_a1 succeeded   age=600s            -> 400  Timestamp outside the tolerance zone
(no Stripe-Signature header)           -> 400  Unable to extract timestamp and signatures from header
evt_a2 payment_failed                  -> 204   order: payment_failed
evt_a3 succeeded                       -> 204   order: paid
evt_a3 succeeded (same id again)       -> 204
evt_a4 payment_failed (arrives late)   -> 204   order: paid
evt_b1 succeeded, pi_not_saved_yet     -> 503

stripe_events: evt_a2, evt_a3, evt_a4
log: ignored out-of-order event {"event":"evt_a4","why":"Illegal order transition paid -> payment_failed"}

(The order: lines came from a tinker query after each call; the send.sh above prints only the status code, and the status-code lines match my run.) Rejected events never reach stripe_events, the duplicate evt_a3 produced no second row, the late failure was recorded but did not un-pay the order, and evt_b1 (unknown intent) returned 503 and left no dedupe row.

Gotchas I hit

  1. php artisan serve ignores container env vars. I passed STRIPE_WEBHOOK_SECRET with docker run -e, and every request returned No webhook secret value was provided. Put the variables in .env.
  2. A helper named post() in a test class is a fatal error, because TestCase::post() is public. I renamed mine deliver().
  3. CSRF is invisible in tests (see above), and the null default status (see the model section) only shows up at runtime. Both passed review until I ran real requests.

What the mock can't tell you

  • Idempotency replay and parameter-mismatch errors (stateless mock); see the not-run recipe above.
  • Real event payloads and API-version shapes. My test events carry only id, type and data.object.id; real events have more, and the API version of the account dictates their shape.
  • Real retry timing, and real out-of-order arrival. I simulated late delivery by sending events in the wrong order.
  • 3D Secure (requires_action) and asynchronous methods (processing).
  • Concurrency. lockForUpdate() is a no-op on SQLite; two simultaneous deliveries of the same order need MySQL or Postgres to test properly. I didn't test this.
  • The crash-between-Stripe-call-and-save recovery is reasoned from the docs, not run.

Before launch, run a test-mode sandbox through the Stripe CLI (stripe listen --forward-to localhost:8000/stripe/webhook), as the docs describe. It uses a real account and test keys, which I didn't have.

What to do next

  1. Move the PaymentService and webhook controller into your Part 2 codebase and replace the demo orders migration with your own columns.
  2. Run stripe listen with a test-mode key and stripe trigger payment_intent.succeeded once, to confirm real payloads match the fields you read.
  3. Move the state-change work into a queued job once it does more than update a row; keep the dedupe insert in the request.
  4. Add a test that sends two identical events concurrently against MySQL or Postgres.
  5. Continue to Part 7 on testing and security.
Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.