<?php
namespace App\Jobs;
use App\Models\Invoice;
use App\Services\StripeGateway;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Queue\SerializesModels;
class ChargeInvoice implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 5;
public int $backoff = 30;
public function __construct(public Invoice $invoice)
{
}
public function middleware(): array
{
return [
(new WithoutOverlapping($this->invoice->id))
->releaseAfter(60)
->expireAfter(180),
];
}
public function handle(StripeGateway $gateway): void
{
$this->invoice->refresh();
if ($this->invoice->paid()) {
return;
}
$charge = $gateway->charge(
amountCents: $this->invoice->amount_cents,
customerId: $this->invoice->stripe_customer_id,
idempotencyKey: 'invoice_' . $this->invoice->id,
);
$this->invoice->markPaid($charge->id);
}
}
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\DB;
class Invoice extends Model
{
protected $fillable = [
'amount_cents',
'stripe_customer_id',
'stripe_charge_id',
'status',
];
public function paid(): bool
{
return $this->status === 'paid';
}
public function markPaid(string $chargeId): void
{
DB::transaction(function () use ($chargeId) {
$fresh = static::query()
->whereKey($this->getKey())
->lockForUpdate()
->firstOrFail();
if ($fresh->status === 'paid') {
return;
}
$fresh->forceFill([
'status' => 'paid',
'stripe_charge_id' => $chargeId,
'paid_at' => now(),
])->save();
$this->setRawAttributes($fresh->getAttributes(), true);
});
}
}
<?php
namespace App\Http\Controllers;
use App\Jobs\ChargeInvoice;
use App\Models\Invoice;
use Illuminate\Http\JsonResponse;
class PaymentController extends Controller
{
public function pay(Invoice $invoice): JsonResponse
{
$this->authorize('pay', $invoice);
if ($invoice->paid()) {
return response()->json([
'status' => 'already_paid',
'charge_id' => $invoice->stripe_charge_id,
]);
}
ChargeInvoice::dispatch($invoice)->onQueue('payments');
return response()->json([
'status' => 'processing',
'invoice_id' => $invoice->id,
], 202);
}
}
This snippet shows how a Laravel queued job protects a payment flow from double-charging when the same job is dispatched more than once — a common outcome of retries, duplicate webhooks, or an impatient user clicking "Pay" twice. The core defense is Laravel's WithoutOverlapping middleware combined with a database-level status guard, so two layers cooperate rather than relying on a single point of failure.
In ChargeInvoice job, the job pins its lock to a specific invoice via ->middleware() returning new WithoutOverlapping($this->invoice->id). This means only one worker at a time may run a charge for that invoice ID; any concurrent job for the same invoice is briefly released back to the queue instead of executing in parallel. The ->releaseAfter(60) call sets how long an overlapping job waits before retrying, and ->expireAfter(180) ensures a crashed worker's lock is eventually freed so the invoice is never permanently stuck.
The middleware alone is not sufficient, because a lock only prevents simultaneous execution — it does not prevent a sequential second charge after the first one already committed. That is why handle() reloads the invoice with refresh() and returns early when paid() is already true. This is the idempotency guard: even if the same job runs twice in sequence, the second run is a no-op. The Stripe call uses an idempotency_key derived from the invoice, giving a third safety net at the payment provider so a retried API request cannot create two charges.
In Invoice model, markPaid() wraps the state transition in a transaction and uses lockForUpdate() inside a fresh query so the row is pessimistically locked while the paid flag flips. paid() centralizes the status check used by the job. In PaymentController, the pay action simply dispatches ChargeInvoice onto the queue; it does not charge inline, keeping the request fast and letting the job's middleware own all concurrency concerns.
The trade-off is added latency and a dependency on a lock store (Redis is typical). The payoff is a billing path that tolerates retries, duplicate dispatches, and worker crashes without ever charging a customer twice. A developer reaches for this pattern whenever an operation has real-world side effects that cannot be safely repeated.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
use crossbeam::channel::unbounded;
use std::thread;
fn main() {
let (tx, rx) = unbounded();
Crossbeam for advanced concurrent data structures
// Creating a Promise
const myPromise = new Promise((resolve, reject) => {
const success = true;
setTimeout(() => {
if (success) {
Promises and async/await patterns for asynchronous JavaScript
use std::sync::mpsc;
use std::thread;
fn main() {
let (tx, rx) = mpsc::channel();
Channels (mpsc) for message passing between threads
Share this code
Here's the card — post it anywhere.