Timers

A node can wait for a point in time and let the engine resume it. No worker sleeps, and no external decision is needed.

use Padosoft\LaravelFlow\Node\NodeResult;

public function execute(NodeContext $context): NodeResult
{
    return NodeResult::pausedUntil(
        now()->addHours(2),
        ['out' => $context->inputs['in']],
    );
}

NodeResult::pausedUntil(DateTimeInterface $resumeAt, array $outputs = []) pauses the node. When $resumeAt arrives the node completes as succeeded with $outputs, and the rest of the graph continues. The handler is not run again on resume.

How it behaves

Where the node runs What happens
Queued run The node is stored as paused with resume_at. A delayed ResumeTimerJob completes it when due. The worker is never blocked.
Synchronous run The executor sleeps inline only when the wait is at most executor.max_inline_delay_seconds (default 5). A longer wait fails the node with a message telling you to run the graph queued.
Dry run The node completes immediately. Nothing waits and nothing is written.
Time already past The node completes immediately.

A timer is not an approval gate: it never issues a token, and nothing can resume it early.

Configuration

'executor' => [
    'max_inline_delay_seconds' => 5,     // longest wait a synchronous run sleeps inline
    'timer_max_job_delay_seconds' => 900, // longest single queue delay (the SQS ceiling)
],

A wait longer than timer_max_job_delay_seconds hops across several delayed jobs, so a 24-hour timer works on SQS.

Schedule the safety net

A delayed job normally resumes each timer. Two cases leave a due timer behind: a job that was lost, and a queue driver that cannot delay (sync, or a driver without delayed dispatch). flow:resume-due-timers resumes every timer that is already due:

// routes/console.php
Schedule::command('flow:resume-due-timers')->everyMinute()->withoutOverlapping();
php artisan flow:resume-due-timers --limit=500   # dispatch a resume job per due timer
php artisan flow:resume-due-timers --sync        # resume in this process
The sync queue driver ignores delays

On queue.default=sync a delayed job runs immediately, before the timer is due, and then stops. The timer stays paused until flow:resume-due-timers runs. In production use a real queue driver and schedule the command.

The command is idempotent. Resuming a timer whose run was cancelled does nothing, and resuming one that was already resumed never re-runs the node or the steps after it.

If the queue is down at the moment the engine enqueues the follow-up coordinator, the resume job fails and is retried. Because the timer keeps its resume_at after it resumes, the retry recognises a completed timer and re-enqueues the coordinator, so a transient outage cannot leave the run stuck.

When no retry is coming (a --sync sweep, or retries exhausted), flow:resume-due-timers also re-drives a completed timer whose run has had no node in flight for more than a minute (including a timer that was the last node, where only the run’s finalize is missing). Re-driving only re-enters the idempotent coordinator, so it is safe on a healthy run too.

Cancelling

Flow::cancel($runId) terminates a timer-paused node like any other paused node. A resume job that fires afterwards finds the node already failed and does nothing.

Persistence

A timer stores flow_run_nodes.resume_at (a nullable timestampTz, indexed with status), the time it is due, and keeps it after it resumes. Only a timer writes it, so an approval pause is never confused with one. The migration is 2026_09_28_000002_add_resume_at_to_flow_run_nodes.php:

php artisan vendor:publish --tag=laravel-flow-migrations
php artisan migrate

Without it, graphs with no timer run unchanged, and a graph that uses one fails with a message naming the missing migration. Restart Octane or queue workers after migrating, because the column’s presence is checked once per process.

The dashboard read model exposes the value as StepSummary::$resumeAt.

Ready-made delay node

The padosoft/laravel-flow-connect package ships a connect.delay node built on NodeResult::pausedUntil().