Updated June 2026. Tested on Laravel 13 and PHP 8.4. Part of the Techalyst queue series.
Sometimes one job needs to run several times, spaced out, until a condition is met. The trick is a job that re-queues itself with release(). Let us build a real example: when a user starts checkout, we reserve their items, nudge them with reminders, and cancel the order if it is still not complete after an hour.
Delay a job for later
First, dispatch a job that checks the order in the future. Chain delay() to hold it back.
class CheckoutController
{
public function store()
{
$order = Order::create(['status' => Order::PENDING, /* ... */]);
MonitorPendingOrder::dispatch($order)->delay(now()->addHour());
}
}
The MonitorPendingOrder job will not be touched by any worker until the hour passes. When it does run, it cancels the order unless it was already completed.
public function handle(): void
{
if (in_array($this->order->status, [Order::CONFIRMED, Order::CANCELED])) {
return; // already resolved, nothing to do
}
$this->order->markAsCanceled();
}
Returning early makes the worker treat the job as successful and remove it from the queue.
A note on SQS: it only allows a delay of up to 15 minutes, and stores a job for at most 12 hours. To delay longer on SQS, delay 15 minutes and keep releasing the job, which is exactly the pattern below.
Make it run several times with release()
Now suppose we want to send an SMS reminder every 15 minutes before cancelling. Instead of waiting a full hour, we delay 15 minutes, and each time the job runs without the order being resolved, it sends a reminder and releases itself for another 15 minutes.
MonitorPendingOrder::dispatch($order)->delay(now()->addMinutes(15));
public function handle(): void
{
if (in_array($this->order->status, [Order::CONFIRMED, Order::CANCELED])) {
return; // user finished or cancelled, stop
}
if ($this->order->olderThan(59, 'minutes')) {
$this->order->markAsCanceled();
return; // an hour is up, cancel and stop
}
Sms::send(/* reminder */);
$this->release(now()->addMinutes(15)); // run me again in 15 minutes
}
release() inside a job does the same thing as delay() does at dispatch: it puts the job back on the queue to run again later.
Give it enough attempts
Every release counts as an attempt, so the job must allow enough tries to cover all of them. Running at 15, 30, 45, and 60 minutes is four runs, so it needs four tries.
class MonitorPendingOrder implements ShouldQueue
{
public $tries = 4;
}
If the user confirms or cancels at, say, 20 minutes, the next run (at 30 minutes) hits the early return, the job is deleted, and no more reminders are sent. The guard at the top of handle() is what makes the job safely stop itself.
A caveat about delays
There is no guarantee a worker picks a job up the instant its delay expires. If the queue is busy and workers are scarce, your reminders may fire late, and the job might not get all four runs in before the hour is up. The fix is operational, not code: run enough workers to keep the queue drained so delayed jobs run close to their scheduled time. If precise timing is critical, watch your queue wait times.
Wrapping up
A self-releasing job is a clean way to run the same work repeatedly on a schedule: dispatch with a delay, do the work, and release() for the next interval, with an early return to stop once the condition is met. Just size $tries to cover every release, and keep enough workers running so the delays are honoured. If the number of releases is unpredictable, swap the fixed $tries for retryUntil, as covered in too many attempts.
More in the series: handling API rate limits in queued jobs. Questions welcome below.
All comments ()
No comments yet
Be the first to leave a comment on this post.