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
| Cause | Node | Resumes when |
|---|---|---|
| Fixed / absolute wait | Wait (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 call | Wait (webhook) | A request reaches the resume endpoint for that execution. No timer — it waits indefinitely. |
| Waiting on a child workflow | Call workflow (in "wait" mode) | The child workflow reaches a terminal state. |
A custom node that returns NodeResult::suspend(...) | yours | Depends 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
ResumeExecutionJobis 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
GETroute, so it works as a plain link in an email. Query-string parameters are forwarded to the waiting node asresume.*, so…/resume/9b1f…c7?decision=approvedgives the node{{ resume.decision }}. - Responds with JSON (
{ "resumed": ["<execution id>"] }) when the caller sendsAccept: 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 returns404.
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, andmanualare fully supported.