Laravel scheduled payments sound trivial until real money moves: charge each loan installment on its due date, exactly once, and keep working when the payment API times out instead of replying. The naive approach — a cron command that loops over due rows and calls the provider — double-charges customers the first time two scheduler runs overlap, or the API charges the card but the HTTP response never arrives and your retry fires a second charge. The fix is a repayment_schedules table with a status machine and a per-installment idempotency key, a daily command that locks each due row before touching it, and reconciliation driven by the provider webhook instead of the synchronous response you may never receive.
Why does a cron-that-charges loop double-charge customers?
Two failure modes, and both are common. First, overlap: your scheduler command runs longer than its interval (a slow payment API will do that), so the next run starts before the previous finishes and both pick up the same due row. Two charges. Second, the timeout case: you POST a charge, the provider debits the card, but the response is lost to a network blip. Your code sees a failure, retries, and the provider — having no way to know it is the same intent — charges again.
You cannot fix this by being careful in PHP. You fix it structurally: a database row lock so a row can only be picked up once, and an idempotency key so the provider itself collapses a retried charge into the original. Get both wrong and you are issuing refunds and writing apology emails.
What does the repayment_schedules table need?
Each installment is a row with its own due date, amount, status, attempt counter, and — critically — a stable idempotency key generated once at creation, not at charge time. The status column is a small state machine: pending to processing to paid, or to failed. The provider reference column is where you store the charge ID the API returns, so settlement webhooks can match back to the row.
Schema::create('repayment_schedules', function (Blueprint $table) {
$table->id();
$table->foreignId('loan_id')->constrained()->cascadeOnDelete();
$table->date('due_date');
$table->unsignedBigInteger('amount'); // store minor units (cents/poisha), never floats
$table->string('currency', 3)->default('BDT');
$table->enum('status', ['pending', 'processing', 'paid', 'failed'])->default('pending');
$table->unsignedTinyInteger('attempts')->default(0);
$table->timestamp('next_attempt_at')->nullable();
$table->uuid('idempotency_key')->unique(); // one stable key per installment
$table->string('provider_reference')->nullable();
$table->timestamps();
// The exact composite the scheduler queries on — see the index guide.
$table->index(['status', 'due_date']);
});Two decisions here save you pain later. Store amounts as integer minor units, never floats — floating point and money do not mix, and a 0.1 + 0.2 rounding error in a finance system is a bug report waiting to happen. And put a real index on (status, due_date) because that is the exact filter the scheduler runs every day; without it the query degrades as the table grows. I go deeper on choosing the right composite index in my database indexing guide.
How do I select due repayments without two runs grabbing the same row?
This is the core of it. Inside a database transaction, select the due, pending rows with lockForUpdate() — that issues a SELECT ... FOR UPDATE which holds a row lock for the life of the transaction. A second overlapping run that tries to read the same rows blocks until the first commits, by which point those rows are already status=processing and no longer match the WHERE clause. Flip the status to processing inside the same transaction, then commit before you ever call the network. Locks and slow HTTP calls do not belong in the same transaction.
One detail that trips people up: filter due_date with a plain where(), not whereDate(). whereDate() wraps the column in SQL's DATE() function, which makes the predicate non-sargable — MySQL cannot use the (status, due_date) index you just added and falls back to a scan, defeating the whole point of the index. Since due_date is a date column, a straight comparison against now() is both correct and index-friendly.
public function handle(PaymentGateway $gateway): int
{
$ids = DB::transaction(function () {
$due = RepaymentSchedule::query()
->where('status', 'pending')
->where('due_date', '<=', now()) // plain where() stays sargable — the index is used
->where(fn ($q) => $q->whereNull('next_attempt_at')
->orWhere('next_attempt_at', '<=', now()))
->lockForUpdate() // SELECT ... FOR UPDATE — no second run can grab these
->limit(200)
->get();
// Mark them processing inside the SAME transaction, then release the lock.
RepaymentSchedule::whereIn('id', $due->pluck('id'))
->update(['status' => 'processing']);
return $due->pluck('id'); // commit happens here — BEFORE any network call
});
// Network calls live OUTSIDE the lock. Queue one job per installment.
foreach ($ids as $id) {
ChargeRepayment::dispatch($id);
}
return self::SUCCESS;
}Notice the command does not call the payment API itself. It claims the rows and dispatches one queued job per installment, so a slow or failing provider never blocks the daily run and each charge retries independently. That offloading only works if your workers are actually running and survive deploys — wire that up the way I describe in running Laravel queue workers under Supervisor.
How does the idempotency key stop a retry from charging twice?
Every serious payment and direct-debit API accepts an idempotency key header (Stripe calls it Idempotency-Key; most direct-debit providers have an equivalent). The provider stores the result against that key for a window. Send the same key twice and you get back the original charge, not a new one. So when your job retries after a lost response, the second request carries the same per-installment key and the provider replies with the charge it already made — no double debit.
The key must be generated once and stored on the row, not regenerated per attempt. That is why idempotency_key is a column, set at installment creation. Reuse it across all attempts for that installment, and never reuse it across different installments.
public function handle(PaymentGateway $gateway): void
{
$repayment = RepaymentSchedule::findOrFail($this->repaymentId);
if ($repayment->status !== 'processing') {
return; // already settled by a webhook or a prior attempt — do nothing
}
try {
$charge = $gateway->charge([
'amount' => $repayment->amount,
'currency' => $repayment->currency,
'loan_id' => $repayment->loan_id,
], idempotencyKey: $repayment->idempotency_key); // SAME key on every retry
// Synchronous OK is provisional — record the reference, let the webhook confirm.
$repayment->update(['provider_reference' => $charge->id]);
} catch (TransientGatewayException $e) {
$repayment->increment('attempts');
if ($repayment->attempts >= 5) {
$repayment->update(['status' => 'failed']); // cap reached — flag for manual review
return;
}
// Exponential backoff: 1m, 4m, 9m, 16m ...
$repayment->update([
'status' => 'pending',
'next_attempt_at' => now()->addMinutes($repayment->attempts ** 2),
]);
$this->release($repayment->attempts ** 2 * 60);
}
}A few reliability rules are baked into that job:
- Only act on a row still in processing — if a webhook already marked it paid, the job exits without touching anything.
- Retry transient failures (timeouts, 5xx, network resets) with exponential backoff so you do not hammer a struggling provider; never retry a hard decline like insufficient funds.
- Cap attempts (5 here) then move the row to failed and surface it for a human — an installment that has failed five times needs a person, not a sixth automated charge.
- Record provider_reference as soon as you have it, even on a provisional success, so the settlement webhook can find this row.
- Throttle outbound calls to the provider's documented limits so a backlog does not trip the gateway's own limiter and start returning 429s mid-run.
The synchronous 200 from a payment API is a promise, not a receipt — only the settlement webhook tells you the money actually moved.
Why reconcile with the webhook instead of trusting the API response?
Direct-debit and many card rails settle asynchronously. The synchronous response means the charge was accepted, not that it cleared — settlement can succeed or bounce hours later, and you only learn the real outcome from the provider's webhook. Treat the inline response as provisional (store the reference, keep the row in processing) and let the webhook be the single source of truth that moves a row to paid or back to failed.
Handle the webhook idempotently too: providers retry deliveries, so the same settlement event can arrive multiple times. Verify the signature, look the row up by provider_reference, and only transition status if it has not already moved. I keep all of this behind one verified entry point — see centralizing payment webhooks in a single Laravel controller for the signature-check and dispatch pattern that keeps every provider's callbacks honest.
How do I wire the command into the scheduler?
Register the command on the scheduler so it fires once a day, with withoutOverlapping() as a second guard on top of the row lock. In Laravel 11 and 12 the schedule lives in routes/console.php (the old app/Console/Kernel.php schedule() method is gone), and a single system cron entry drives the whole thing.
use Illuminate\Support\Facades\Schedule;
use App\Console\Commands\ChargeDueRepayments;
Schedule::command(ChargeDueRepayments::class)
->dailyAt('09:00')
->withoutOverlapping() // cache lock so a long run never overlaps the next
->onOneServer(); // on multi-server setups, only one box runs it* * * * * cd /var/www/app && php artisan schedule:run >> /dev/null 2>&1onOneServer() matters the moment you run more than one app server — without it, every box runs schedule:run and you are back to overlapping charges across machines. It requires a shared cache lock store (Redis or database), so make sure your cache driver is shared, not per-server file cache.
Put these pieces together and recurring loan repayments stop being a source of dread. The row lock guarantees an installment is claimed once; the idempotency key guarantees the provider charges it once even when your retry fires; the attempt cap turns a stuck installment into a support ticket instead of a runaway debit; and the webhook gives you a truthful ledger instead of an optimistic one. Money systems fail loudly when they double-charge and silently when they skip a due date — design for both, lock before you charge, and let the webhook have the last word.

