Idempotent Scheduled Jobs in Laravel: lockForUpdate + Status Guards
Language Mismatch Disclaimer: Please be aware that the language of this article may not match the language settings of your browser or device.
Do you want to read articles in English instead ?
Any scheduled job worth writing changes real-world state: it sends a reminder, charges a card, escalates a case. And any job like that will, sooner or later, be asked to do the same thing twice. A queue retry after a timeout. Two overlapping ticks because the last run ran long. A user double-clicking the button that dispatches the same work. The question is never "will this run more than once." It is "what happens when it does."
The naive version double-acts. Two reminders land in the same inbox, one card gets charged twice, one case escalates two rungs for a single event. This is the pattern I reach for to stop that from happening: a Laravel idempotent scheduled job that is safe by construction, not by hoping the trigger only fires once. Three moving parts: poll current state, lock the row, guard on status.
Poll current state, do not pre-schedule the action
The first instinct is usually to dispatch a delayed job the moment the triggering record is created. Invoice becomes due in 30 days? Queue EscalateOverdueInvoice to fire on the due date. It reads clean until the due date moves. Now you have a queued job pointing at a stale deadline, and you have to hunt it down and cancel it. The queue has quietly become a second source of truth you have to keep in sync with the database.
I do the opposite. A single command runs on a schedule and re-reads current state every time:
// routes/console.php
use Illuminate\Support\Facades\Schedule;
Schedule::command('invoices:escalate-overdue')
->hourly()
->withoutOverlapping()
->onOneServer();
withoutOverlapping stops a slow run from colliding with the next tick. onOneServer means only one node in a multi-server deployment picks up the job. Both matter, but neither is the actual safety net. They reduce how often duplicate work is attempted; they do not make the work itself safe. That is the row lock's job.
The command is deliberately dumb. It finds the rows that currently qualify and hands each one to an action:
// app/Console/Commands/EscalateOverdueInvoices.php
namespace App\Console\Commands;
use App\Actions\EscalateOverdueInvoice;
use App\Enums\InvoiceStatus;
use App\Models\Invoice;
use Illuminate\Console\Command;
class EscalateOverdueInvoices extends Command
{
protected $signature = 'invoices:escalate-overdue';
protected $description = 'Advance every overdue invoice one rung down the dunning ladder.';
public function handle(EscalateOverdueInvoice $escalate): int
{
Invoice::query()
->where('status', InvoiceStatus::Overdue)
->where('due_at', '<', now())
->pluck('id')
->each(fn (int $invoiceId) => $escalate($invoiceId));
return self::SUCCESS;
}
}
Because it re-reads state each run, an invoice that gets paid or rescheduled between ticks simply stops matching the query. There is no stale job to cancel, no reconciliation. The schedule is self-correcting.
Lock the row, then re-read under the lock
Selecting the rows in the command is not enough. Between the SELECT in handle and the moment the action runs, another process (a retry, the next tick, a double-clicked button hitting the same action directly) can already be working on the same invoice. The list of ids is a snapshot, and the snapshot is stale the instant it is read.
So the action takes a fresh, exclusive lock on the one row it is about to touch, inside a transaction:
// app/Actions/EscalateOverdueInvoice.php
namespace App\Actions;
use App\Enums\InvoiceStatus;
use App\Exceptions\DunningLadderExhausted;
use App\Models\Invoice;
use App\Notifications\DunningNotification;
use Illuminate\Support\Facades\DB;
class EscalateOverdueInvoice
{
public function __invoke(int $invoiceId): void
{
DB::transaction(function () use ($invoiceId) {
$invoice = Invoice::query()
->whereKey($invoiceId)
->lockForUpdate()
->first();
if ($invoice === null) {
return;
}
// Guard clauses: bail the moment the world no longer matches
// what the command assumed when it picked this row.
if ($invoice->status !== InvoiceStatus::Overdue) {
return; // a concurrent run already handled it
}
if ($invoice->due_at->isFuture()) {
return; // rescheduled since it was picked up
}
$nextStep = $invoice->dunning_step->next()
?? throw new DunningLadderExhausted($invoice->id);
$invoice->update([
'status' => InvoiceStatus::Escalated,
'dunning_step' => $nextStep,
'last_escalated_at' => now(),
]);
$invoice->notify(new DunningNotification($nextStep));
});
}
}
lockForUpdate issues a SELECT ... FOR UPDATE. The first transaction to reach that row holds the lock until it commits. Any second transaction blocks there and waits. When the first one commits and releases, the second one finally reads the row and sees the state the first one left behind. That is the whole trick: the second reader does not see the stale snapshot the command handed it, it sees the committed truth.
Which is where the status guard earns its keep.
The status guard collapses duplicates to one
The lock serializes the two runs. The guard is what makes the second run a no-op. The action's contract is: I only act on an invoice whose status is Overdue, and my first move is to change that status to Escalated. So when a duplicate run finally acquires the lock and re-reads, the status is no longer Overdue, the guard clause fires, and it returns without sending a second notification.
A retry, a duplicate poll, and a double-clicked button all collapse into a single escalation. Notice what is not here: no Cache::lock with a TTL to tune, no dedup key, no exponential backoff, no "did I already send this" bookkeeping table. The database row is the lock and the state machine at once. There is nothing unsafe to retry, so there is nothing to configure.
The dunning ladder itself is just an enum that knows its own successor, and the action throws a dedicated exception when it runs off the end:
// app/Enums/DunningStep.php
enum DunningStep: string
{
case Reminder = 'reminder';
case Warning = 'warning';
case FinalNotice = 'final_notice';
public function next(): ?self
{
return match ($this) {
self::Reminder => self::Warning,
self::Warning => self::FinalNotice,
self::FinalNotice => null,
};
}
}
A separate concern re-marks the invoice Overdue when the next deadline lapses, and the engine walks it one rung further on the following tick. Detection and action stay independent, which is what keeps each of them simple.
Prove it with a test that runs the action twice
The claim is "runs more than once, acts once." A test should make the job run more than once and assert it acted once. That is a two-line change from a happy-path test:
use App\Actions\EscalateOverdueInvoice;
use App\Enums\DunningStep;
use App\Enums\InvoiceStatus;
use App\Models\Invoice;
use App\Notifications\DunningNotification;
use Illuminate\Support\Facades\Notification;
it('collapses a double run into a single escalation', function () {
Notification::fake();
$invoice = Invoice::factory()->create([
'status' => InvoiceStatus::Overdue,
'dunning_step' => DunningStep::Reminder,
'due_at' => now()->subDay(),
]);
$escalate = app(EscalateOverdueInvoice::class);
$escalate($invoice->id); // the real tick
$escalate($invoice->id); // a retry / duplicate poll / double click
$invoice->refresh();
expect($invoice->status)->toBe(InvoiceStatus::Escalated);
expect($invoice->dunning_step)->toBe(DunningStep::Warning);
Notification::assertSentTimes(DunningNotification::class, 1);
});
it('throws when the ladder is already exhausted', function () {
$invoice = Invoice::factory()->create([
'status' => InvoiceStatus::Overdue,
'dunning_step' => DunningStep::FinalNotice,
'due_at' => now()->subDay(),
]);
expect(fn () => app(EscalateOverdueInvoice::class)($invoice->id))
->toThrow(App\Exceptions\DunningLadderExhausted::class);
});
The first test calls the action twice in a row and asserts one status change, one rung advanced, one notification. The second locks in the exhaustion behavior so a later refactor cannot silently swallow the end of the ladder. Note that a single-process test proves the guard logic, not the locking under real contention: lockForUpdate needs a lock-aware driver, so I run this suite against Postgres or MySQL rather than SQLite in-memory when I want the lock itself exercised.
Where this came from
I lean on this pattern most recently on a K-12 platform, where this runs a no-show escalation engine: when a student misses an assigned intervention, an hourly job detects it and advances a fixed ladder of consequences, and the row lock plus status guard mean it cannot escalate the same student twice no matter how often it runs. The full write-up of that build, the trade-offs and why I rejected the delayed-job design, is in the no-show escalation engine case study. The broader shape of the work I do is on my full-stack Laravel and Vue resume.
The pattern generalizes past escalation ladders. Anywhere a scheduled job mutates state that a human would notice being done twice, the same three parts apply: poll current state so there is nothing stale to reconcile, lock the row so duplicate runs serialize, guard on status so only the first one through does the work. Idempotency stops being a property you hope for and becomes one the code cannot violate.