A webhook and a user clicking Pay can dispatch the same invoice together. Two workers then update one row, and the second write wins without either of them noticing.
WithoutOverlapping takes a cache lock for the key you give it. The second job does not enter handle() until the first has finished and released the lock.
01 What it does
The constructor releases a blocked job immediately, which puts it back on the queue in a tight loop. Each pickup counts as an attempt, so $tries runs out and the job fails without ever getting the lock. Give it a pause, and give the lock an expiry so a crashed worker cannot hold the invoice forever.
// app/Jobs/RecordInvoicePayment.php
use Illuminate\Queue\Middleware\WithoutOverlapping;
class RecordInvoicePayment
{
/**
* @return array<int, WithoutOverlapping>
*/
public function middleware(): array
{
return [
(new WithoutOverlapping('invoice:'.$this->invoiceId))
->releaseAfter(15)
->expireAfter(180),
];
}
}releaseAfter(15) waits fifteen seconds before the blocked job is available again. expireAfter(180) drops the lock after three minutes even if the worker dies. Set the expiry longer than the job can legitimately run.
The lock key is per job class unless you share it. A charge job and a refund job can still overlap on the same invoice if each has its own key.
return [
(new WithoutOverlapping('invoice:'.$this->invoiceId))
->shared()
->releaseAfter(15)
->expireAfter(180),
];shared() stops Laravel prefixing the key with the class name. Use the same string in every job that mutates that invoice.
02 Gotchas
dontRelease()drops the blocked copy instead of putting it back. It is not written tofailed_jobs. Use it only when another run will finish the work.- The lock ends when
handle()returns. A retry after that needs the job itself to be safe. Jobs that don’t fall over is that half. - The lock uses the cache store. A store that forgets locks, or two apps sharing a prefix, will overlap or block each other.
expireAfter(0)means the lock does not expire. A dead worker then holds the invoice until someone clears the cache.
03 Related
Unique jobs and WithoutOverlapping compares this with ShouldBeUnique. Jobs that don’t fall over explains $tries and the attempt UUID.