Skip to main content

Suspend & resume

A workflow can pause partway through and continue later without re-running the steps that already ran. When it pauses, the execution's status becomes Suspended and its full runtime state (node outputs, variables, loop cursors, the current position) is persisted.

What causes a suspend

CauseNodeResumes when
Fixed / absolute waitWait (duration / date)The time elapses. In sync mode, a wait shorter than sync_wait_timeout resolves in-process instead of suspending.
Waiting for a webhook callWait (webhook)A request reaches the resume endpoint for that execution. No timer — it waits indefinitely.
Waiting on a child workflowCall workflow (in "wait" mode)The child workflow reaches a terminal state.
A custom node that returns NodeResult::suspend(...)yoursDepends on the resume token you used (see below).

How resumption happens

When an execution suspends, a scheduler inspects the resume token and does the right thing automatically:

  • Timed wait → a delayed ResumeExecutionJob is queued for when the wait elapses (or, for short sync waits, the process sleeps and resumes inline).
  • Webhook wait → nothing is scheduled. The execution stays suspended until a request hits its resume endpoint; that request resumes it inline, no queue worker needed.
  • Child workflow → the child is started. When it finishes, the parent is resumed with the child's output stamped onto the waiting node.
  • Manual → nothing is scheduled. The execution stays suspended until your code calls Workflow::resume().

For queue-backed resumption (timed waits, queued child workflows) you need a running queue worker.

Resuming a webhook wait

A Wait node with waitType: webhook suspends the execution and waits for an HTTP call — no timeout. This is the built-in primitive for approvals and other "pause until something outside says go" steps.

The token that identifies the suspended execution is the execution's own id. Build the resume URL from it:

use Qanna\WorkflowEngine\Http\Controllers\ResumeWebhookController;

$execution = Workflow::run('expense-approval', ['amount' => 4200]);

// $execution->status === ExecutionStatus::Suspended
$resumeUrl = ResumeWebhookController::url($execution->id);
// e.g. https://your-app.test/workflowengine/resume/9b1f…c7 (a GET route)

Mail::to($approver)->send(new ApprovalRequest($resumeUrl));

Calling that URL:

  • Is a GET route, so it works as a plain link in an email. Query-string parameters are forwarded to the waiting node as resume.*, so …/resume/9b1f…c7?decision=approved gives the node {{ resume.decision }}.
  • Responds with JSON ({ "resumed": ["<execution id>"] }) when the caller sends Accept: application/json, otherwise a plain-text confirmation suitable for a browser tab.
  • Is single-use — once the execution has resumed it is no longer suspended, so a second call to the same URL returns 404. An unknown or already-used token also returns 404.

The Wait node's output after a webhook resume is:

{ "resumed_via": "webhook", "payload": { "decision": "approved" } }

The route path and middleware are configurable under resume_webhook.

Resuming manually

use Qanna\WorkflowEngine\Facades\Workflow;

$resumed = Workflow::resume($executionId, ['approved' => true, 'note' => 'LGTM']);

The payload is placed in context as resume.* and is visible to the node that was waiting — e.g. {{ resume.approved }}. Calling resume() on an execution that isn't suspended throws.

Resume tokens (for custom nodes)

A custom node suspends by returning NodeResult::suspend($token) where $token is a ResumeToken:

use Qanna\WorkflowEngine\Engine\Resume\ResumeToken;

ResumeToken::wait($carbonInstant); // resume after a point in time
ResumeToken::workflow($workflowId, $payload = []); // resume after a child workflow finishes
ResumeToken::webhook(); // resume via the execution's resume endpoint (see above)
ResumeToken::event($eventName, $payload = []); // resume on an application event
ResumeToken::manual($reason = null); // resume only via Workflow::resume()

A resumed node runs its handle() again from the top. Persist a flag in the node's state on the first pass so the second pass can tell it's a resume and fall through instead of suspending again:

public function handle(WorkflowContext $context, array $config): NodeResult
{
if ($this->hasState('requested')) {
return NodeResult::success(['approved' => $context->get('resume.approved')]);
}

$this->remember('requested', true);

return NodeResult::suspend(ResumeToken::manual('needs approval'));
}

Node state (remember() / state() / hasState()) is persisted across the suspend and restored before the second call. See Custom nodes.

Note: ResumeToken::event() tokens are minted but the engine does not yet wire up an application-event listener to deliver them — treat event-based resume as not implemented. wait, workflow, webhook, and manual are fully supported.