All posts

The Friday deploy that charged customers twice

Published June 13, 2026 · by Majd Ghithan, written with the help of AI

The Friday deploy that charged customers twice

It was 4:40 on a Friday. The change was tiny — a one-line tweak to how we called the payment provider, plus a bump to the queue worker's timeout. Small, tested, reviewed. I deployed it and started closing tabs.

At 5:15 the first email came in: "Why was I charged twice?" Then another. Then eleven more. We had double-charged real customers on real cards, and every minute the deploy stayed live, the number grew.

This is the story of that hour, because the bug wasn't really in the one line I changed. The one line just woke up a bug that had been asleep in our payment job since the day it was written.

What actually happened

Our "charge the customer" job called the payment provider, then recorded the charge in our database. Simple. It had run cleanly for months.

The provider's API was occasionally slow — sometimes a call took 30 seconds. Our old worker timeout was 60 seconds, so it never mattered. My deploy lowered the worker timeout to 25 seconds "to fail faster." Reasonable in isolation. Catastrophic here.

Here's the sequence, and it's worth reading slowly:

The double-charge, step by step (seconds into the job)
0252930Job starts, calls providerWorker timeout kills the jobProvider actually completes the chargeQueue retries the job → charges AGAIN
sec

The job called the provider. At 25 seconds the worker timed out and was killed — before the provider responded. The charge went through anyway, at 29 seconds, but our job was already dead and never recorded it. The queue saw an unfinished job and did exactly what queues are designed to do: it retried. The retry called the provider again. Second charge. Same card.

The job assumed it would run exactly once. Queues never promise that. They promise at least once.

The hour

  • 5:15 — First reports. I think it's a support fluke.
  • 5:20 — Fourth report. I pull the payment logs and see pairs of charges seconds apart. Stomach drops.
  • 5:25 — I roll back the deploy. This stops new double-charges but does nothing for the ones already made.
  • 5:30 — We pause the payment queue entirely so no more retries can fire.
  • 5:45 — Script written to find every double charge in the window and refund the duplicates.
  • 6:30 — Refunds issued, affected customers emailed a real apology before they had to ask.

Rolling back stopped the bleeding. It did not undo the damage. That distinction is the whole reason this was a bad afternoon and not a five-minute fix.

The real fix: make the job idempotent

The timeout change was just the trigger. The actual defect was that our charge job could run twice and produce two charges. The fix is to make the second run a no-op — an idempotency key the provider recognizes:

public function handle(PaymentGateway $gateway): void
{
    // A stable key derived from the thing we're charging FOR,
    // not from the attempt. Same order = same key, every retry.
    $key = "charge:order:{$this->order->id}";

    $gateway->charge(
        amount: $this->order->total,
        source: $this->order->payment_method,
        idempotencyKey: $key, // provider dedupes on this
    );
}

Every serious payment provider supports an idempotency key. You send the same key on a retry, and the provider recognizes it and returns the original charge instead of making a new one. Our first attempt and the retry would have carried the identical key, the provider would have collapsed them into one charge, and no customer would ever have known there was a timeout at all.

We added a second layer too — record "charge started" with the key before calling the provider, so even our own code can check "did I already try this?" and refuse to double-fire.

What else changed

Idempotency was the fix. Two process changes made sure the next one couldn't get this far:

Feature flags on risky changes. The timeout change now would ship behind a flag, off by default, flipped on for 5% of traffic first. A double-charge at 5% is 12 angry emails. At 100% it was hundreds. The flag is the difference between a scare and an incident.

No Friday-afternoon deploys to the payment path. Not superstition — math. The cost of a bug is the size of the blast times how long it stays live. Friday at 4:40 maximizes the second term: everyone's leaving, monitoring attention is at its weekly low, and the person who could fix it fastest is already on the train home. The same deploy Tuesday morning would have been caught in ten minutes by someone actually watching.

The uncomfortable truth is that none of my "small, tested, reviewed" was wrong on its own. The timeout change was sane. The code passed. The review found nothing — because the bug wasn't in the diff, it was in an assumption the diff happened to expose. That's what these incidents almost always are: a latent assumption, sleeping quietly at low volume and short timeouts, waiting for one innocent change to turn the conditions just enough to wake it up.

🤖 Heads up: this post was drafted with AI. Spot something wrong or off?Edit it on GitHub & open a PR
Majd Ghithan

Majd Ghithan

Full-Stack Engineer & Tech Lead

More posts

Laravel finally resizes images for you

Laravel finally resizes images for you

Every Laravel app I've built that takes an avatar ended the same way: composer require intervention/image, wire up a facade, write a little service class, and hope the next dev…

July 26, 2026Read more
What actually breaks at 100,000 users (that never breaks in a demo)

What actually breaks at 100,000 users (that never breaks in a demo)

The first time I shipped a dashboard to a hundred thousand active users, nothing I had tested was what broke. Everything that failed had passed every check I ran. It worked on m…

July 25, 2026Read more
Where your queue jobs go to die under load

Where your queue jobs go to die under load

The support ticket said "I never got my invoice email." I checked the logs. The job ran. It succeeded. failedjobs was empty. Everything said the email went out. It did not.

July 18, 2026Read more
The indexes you're missing (and the one that's hurting you)

The indexes you're missing (and the one that's hurting you)

A client sent me a query that took 4.2 seconds. One WHERE, one ORDER BY, a table with three million rows. I added a single index and it dropped to 11 milliseconds. They asked if…

July 11, 2026Read more
Caching in Laravel without the stampede

Caching in Laravel without the stampede

We cached the homepage's "trending products" query for five minutes. It was our slowest query — about 900ms, a big aggregation across orders. Caching it took the homepage from s…

July 4, 2026Read more
Taking one endpoint from 800ms to 80ms

Taking one endpoint from 800ms to 80ms

The order-details endpoint took 800 milliseconds. Not broken, just slow enough that the app felt heavy everywhere it was used. The team's instinct was "the server needs more mem…

June 27, 2026Read more
The N+1 you can't see (it's hiding in your accessors)

The N+1 you can't see (it's hiding in your accessors)

I've fixed hundreds of N+1 queries. The easy ones are right there in the controller — a foreach with $order-customer inside it, obvious the moment you read the code. Those aren'…

June 20, 2026Read more
Code review people don't dread

Code review people don't dread

I once left forty-one comments on a junior's pull request. I was proud of it. Thorough, I told myself. The next day he barely made eye contact, and his next PR sat open for a we…

June 6, 2026Read more
Hiring a mid-level Laravel dev: the signals that actually matter

Hiring a mid-level Laravel dev: the signals that actually matter

The best hire I ever made failed my first question. I asked him to explain service containers and he stumbled, went quiet, then said "honestly I use them every day but I've neve…

May 30, 2026Read more
Saying no to a feature without being the 'no' person

Saying no to a feature without being the 'no' person

For about a year I was the engineer everyone learned to route around. Not because I was wrong, I was usually right, but because my answer to new ideas was a flat "no, that'll br…

May 23, 2026Read more
My first 90 days as a tech lead (and the habit I had to break)

My first 90 days as a tech lead (and the habit I had to break)

Three weeks into leading my first team, I stayed late to "help" by rewriting a junior's feature myself. It was faster my way. I pushed it, felt productive, went home. The next m…

May 16, 2026Read more
Breaking down the task that scares you

Breaking down the task that scares you

There's a specific kind of ticket that makes my stomach drop. Not the hard ones, hard is fine. It's the ambiguous ones. "Migrate billing to the new provider." "Add multi-tenancy…

May 9, 2026Read more
1:1s that aren't just status updates

1:1s that aren't just status updates

For my first few months as a lead, my 1:1s were thirty minutes of me asking "so, what are you working on?" and nodding at answers I already knew from standup. We both left sligh…

May 2, 2026Read more
Get the business logic out of your controllers

Get the business logic out of your controllers

I once opened a OrderController@store method that was 240 lines long. Validation, a discount calculation, three model writes, a Stripe charge, two emails, a Slack notification,…

April 25, 2026Read more
Form Requests are the most underrated thing in Laravel

Form Requests are the most underrated thing in Laravel

Most Laravel devs meet Form Requests once, in a tutorial, use them to hold a rules() array, and never look deeper. That's a shame, because a Form Request is the cleanest place i…

April 18, 2026Read more
Testing Laravel without mocking everything

Testing Laravel without mocking everything

I inherited a codebase once with 900 unit tests and no confidence. Every test mocked the repository, mocked the model, mocked the mailer, mocked the thing three layers down. The…

April 11, 2026Read more
Three Eloquent features that clean up your models

Three Eloquent features that clean up your models

The messiest Laravel model I ever wrote wasn't messy because of Eloquent. It was messy because I ignored the parts of Eloquent that exist specifically to keep it clean. The same…

April 4, 2026Read more
The migration that locked the table for eight minutes

The migration that locked the table for eight minutes

The deploy looked boring. One migration, adding a lastseenat column to the users table with a default of now(). I'd written a hundred like it. I ran it at 2pm on a Tuesday becau…

March 28, 2026Read more
Chasing a memory leak in a Laravel queue worker

Chasing a memory leak in a Laravel queue worker

The alert came in at 4am: one of our queue:work processes had been OOM-killed. The supervisor restarted it, it processed jobs for about forty minutes, and got killed again. It w…

March 21, 2026Read more