Skip to content
experience

Our worst week this year: a queue job that charged one customer three times

Ayesha Siddiqui@laravel_ayesha

October 21, 2026 · 3 min read

I post the wins here, so I should post this one too.

UK ecommerce client, Laravel, a payment gateway the client chose (a smaller provider, not Stripe). Checkout puts a ChargeOrder job on the queue. The job had $tries = 3 with backoff, because "network errors happen". One evening the gateway was slow. The charge request took longer than our HTTP timeout, so the job threw a timeout exception. Laravel did exactly what we told it: retried. Twice.

The gateway had not failed. It was just slow. All three charges went through. £189, three times, on one customer's card. She found out from her banking app before we found out from our logs.

What we got wrong

  • We treated a timeout as a failure. A timeout means "I don't know". The money might have moved.
  • We retried a non-idempotent call to the outside world. This gateway has no idempotency key support, so every retry was a new charge.
  • Nothing in our database said "a charge is in flight for this order", so every attempt looked like the first.

The fix

class ChargeOrder implements ShouldQueue
{
    use Queueable;

    public int $tries = 1; // never retry a charge blindly

    public function __construct(public int $orderId) {}

    public function handle(Gateway $gateway): void
    {
        $mine = DB::transaction(function () {
            $order = Order::lockForUpdate()->findOrFail($this->orderId);

            if ($order->payment_state !== PaymentState::Pending) {
                return false; // someone already tried, reconcile decides
            }

            $order->update(['payment_state' => PaymentState::Charging]);

            return true;
        });

        if (! $mine) {
            return;
        }

        try {
            $txn = $gateway->charge($this->orderId, reference: "order-{$this->orderId}");

            Order::whereKey($this->orderId)->update([
                'payment_state' => PaymentState::Paid,
                'gateway_txn' => $txn->id,
            ]);
        } catch (GatewayTimeout $e) {
            // we do NOT know if it charged. ask the gateway later, by our reference.
            ReconcilePayment::dispatch($this->orderId)->delay(now()->addMinutes(2));
        }
    }
}

The order moves Pending → Charging → Paid, and only a job that wins the row lock while the order is still Pending is allowed to call the gateway. On a timeout, a separate ReconcilePayment job asks the gateway for the transaction by our reference and only then decides: mark it paid, or put it back to Pending so the customer can try again.

If your gateway supports idempotency keys (Stripe does), pass one. With Stripe's PHP library it is one extra array: ['idempotency_key' => "charge-order-{$order->id}"]. Retries become safe. Ours does not, so we built the state machine. Should have built it on day one anyway.

We refunded the customer the same evening, the client kept us (alhamdulillah), and I re-read emeka_golang's idempotency post from July with a lot of regret. Timeouts are "I don't know". Write it on the wall.

Get the best of #php_still_pays weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

11

Sign in to join the conversation.

Sign in to comment
Oluwatobi Adeyemi@tobi_freelance#6 in PHP Still Pays
Omo. The 'charging' state before the call is so simple and so important. I've seen this exact bug with retries on a client's wallet app, same story, different country
2 net votes
Arjun Mehta@arjunmehta1 rep in PHP Still Pays
Pending, charging, paid, plus reconcile is exactly the state machine we use for bank debits. One addition: alert when any order sits in charging for more than 10 minutes. That alert has found two gateway outages before the gateway told us.
5 net votes
Ayesha Siddiqui@laravel_ayesha#1 in PHP Still Pays
adding the alert today itself, as you would say 😄 thank you Arjun
4 net votes
Emeka Nwosu@emeka_golang3 rep in PHP Still Pays
Never retry blind. Na the whole lesson. Timeouts are 'I don't know', not 'it failed'. Sorry about the week Ayesha, this kind one hurts because the customer sees it
2 net votes
Ayesha Siddiqui@laravel_ayesha#1 in PHP Still Pays
'timeouts are I don't know' is literally going on the office wall tomorrow. and yes, the customer email was not fun
4 net votes
Imran Hussain@imran_builds#3 in PHP Still Pays
right, they're down. read this twice. we've got a charge job with tries=3 on a gateway with no idempotency, so I know what I'm doing tomorrow morning. thanks for writing it up rather than burying it
4 net votes
Faizan Qureshi@faizan_php#4 in PHP Still Pays
Baji, aapki har post se kuch seekhta hoon. Is baar seekha ki timeout ka matlab fail nahi hota. Note kar liya 🙏
1 net votes
Bayu Setiawan@bayu_laravel#2 in PHP Still Pays
Thank you for sharing honestly mbak. Many agency only post success. I will check our koperasi payment jobs today, I think we also retry blind 😬
4 net votes
Mehmet Aydoğan@malloc_mehmet#8 in PHP Still Pays
In embedded we call this exactly-once delivery and we know it does not exist. Good that PHP people discover it too.
4 net votes
Ayesha Siddiqui@laravel_ayesha#1 in PHP Still Pays
Mehmet that is almost a compliment. saving it 😊
3 net votes
Mehmet Aydoğan@malloc_mehmet#8 in PHP Still Pays
It is not.
1 net votes