Workflow Signals
Workflows can pause and wait for external input using signals. The wait_for_signal() and wait_for_signals() builder methods insert steps that block the workflow until your application, an operator, or another system sends the expected signal via Queuety::signal().
For human-in-the-loop flows, Queuety also includes:
wait_for_approval()for a namespaced approval payloadwait_for_input()for structured human inputwait_for_decision()for explicit approve/reject outcomes
For a bigger end-to-end example that combines agent runs with human gates, see Agent Orchestration.
The ->wait_for_signal() method
Add a signal wait step to a workflow:
use Queuety\Queuety;
$workflow_id = Queuety::workflow( 'content_approval' )
->then( DraftContentHandler::class )
->wait_for_signal( 'approved' )
->then( PublishContentHandler::class )
->dispatch( [ 'post_id' => 99 ] );When the workflow reaches the wait_for_signal step, it sets its status to waiting_for_signal and pauses. No jobs are running and no resources are consumed while waiting.
By default, a single signal payload is merged into the top-level workflow state. If you want the payload namespaced instead, pass a result_key:
$workflow_id = Queuety::workflow( 'content_approval' )
->then( DraftContentHandler::class )
->wait_for_signal( 'approved', 'approval' )
->then( PublishContentHandler::class )
->dispatch( [ 'post_id' => 99 ] );After the signal arrives, $state['approval'] contains the signal payload array.
Waiting for multiple signals
Use wait_for_signals() when the workflow should unblock on several possible signals, or when it must collect all required signals before continuing.
use Queuety\Enums\WaitMode;
$workflow_id = Queuety::workflow( 'moderation_gate' )
->then( SubmitForReviewHandler::class )
->wait_for_signals(
[ 'editor_approved', 'legal_approved' ],
WaitMode::All,
'approvals'
)
->then( PublishContentHandler::class )
->dispatch();WaitMode::Allwaits until every configured signal has been recorded.WaitMode::Anyresumes as soon as the first configured signal arrives.
With a result_key, a multi-signal wait stores an associative array keyed by signal name:
[
'editor_approved' => [ 'by' => 'editor@example.com' ],
'legal_approved' => [ 'by' => 'legal@example.com' ],
]Without a result_key, the matched signal payloads are merged into top-level state in the order the step was configured.
Matching and correlation
Signal waits can ignore unrelated events by matching payload content or correlating on a shared key.
$workflow_id = Queuety::workflow( 'review_gate' )
->wait_for_signal(
name: 'review_ready',
result_key: 'review',
match_payload: [ 'source' => 'editorial' ],
correlation_key: 'task_id',
)
->dispatch( [ 'task_id' => 'task-42' ] );In that example, the wait only resumes when:
- the signal name is
review_ready - the payload contains
source: editorial - the payload
task_idmatches$state['task_id']
This is useful when several external systems or agents publish the same event names but only one payload belongs to the current workflow run.
Human approval and input helpers
wait_for_approval() and wait_for_input() are convenience wrappers around signal waits. They behave like wait_for_signal() but default to a namespaced result key.
$workflow_id = Queuety::workflow( 'blog_review' )
->then( GenerateDraftHandler::class )
->wait_for_approval()
->wait_for_input()
->then( HandleDecisionHandler::class )
->dispatch();After the approval signal, $state['approval'] contains the approval payload. After the input signal, $state['input'] contains the input payload.
Use wait_for_decision() when you want a single step to resume on either approval or rejection:
$workflow_id = Queuety::workflow( 'legal_review' )
->then( DraftContractHandler::class )
->wait_for_decision( result_key: 'review' )
->then( HandleReviewDecisionHandler::class )
->dispatch();After the signal arrives, $state['review'] contains:
[
'outcome' => 'approved', // or 'rejected'
'signal' => 'approved', // or 'rejected'
'data' => [ 'reviewer' => 'ops@example.com' ],
]Sending a signal
Send a signal to a workflow using Queuety::signal():
Queuety::signal( $workflow_id, 'approved' );When the signal matches the one the workflow is waiting for, the workflow resumes and advances to the next step.
For human-in-the-loop flows, the facade also exposes semantic helpers:
Queuety::approve_workflow( $workflow_id, [ 'reviewer' => 'editor@example.com' ] );
Queuety::reject_workflow( $workflow_id, [ 'reason' => 'needs citations' ] );
Queuety::submit_workflow_input( $workflow_id, [ 'notes' => 'Ship after copy edit' ] );Signal data
You can pass a data array with the signal. The data is merged into the workflow state, making it available to subsequent steps:
Queuety::signal( $workflow_id, 'approved', [
'approved_by' => 'admin@example.com',
'approved_at' => time(),
] );In the next step:
class PublishContentHandler implements Step {
public function handle( array $state ): array {
// $state['approved_by'] is 'admin@example.com'
// $state['approved_at'] is the timestamp
// $state['post_id'] is 99
wp_update_post( [
'ID' => $state['post_id'],
'post_status' => 'publish',
] );
return [ 'published' => true ];
}
public function config(): array {
return [];
}
}Keys that start with _ (underscore) are reserved for internal use and are not merged into the state.
Pre-existing signals
Signals can be sent before the workflow reaches the wait step. When a signal arrives, it is recorded in the queuety_signals table regardless of the workflow's current status. When the workflow later reaches the wait_for_signal step, it checks for pre-existing signals and continues immediately if one is found.
This means the order does not matter:
// Dispatch workflow
$workflow_id = Queuety::workflow( 'order_fulfillment' )
->then( PrepareOrderHandler::class )
->wait_for_signal( 'payment_confirmed' )
->then( ShipOrderHandler::class )
->dispatch( [ 'order_id' => 123 ] );
// Signal arrives before the workflow reaches the wait step
Queuety::signal( $workflow_id, 'payment_confirmed', [ 'transaction_id' => 'txn_abc' ] );
// When the workflow finishes PrepareOrderHandler and reaches the wait step,
// it sees the pre-existing signal and continues immediately.Workflow status during signal wait
While waiting for a signal, the workflow status is WorkflowStatus::WaitingForSignal (waiting_for_signal):
$state = Queuety::workflow_status( $workflow_id );
echo $state->status->value; // 'waiting_for_signal'
echo $state->wait_type; // 'signal'
echo $state->wait_mode; // 'all' or 'any'
print_r( $state->waiting_for ); // ['approved']
print_r( $state->wait_details ); // matched/remaining, correlation info, result key, ...Use case: human approval
Build an approval workflow where a human reviewer must approve content before it goes live:
$workflow_id = Queuety::workflow( 'blog_review' )
->then( GenerateDraftHandler::class )
->then( NotifyReviewerHandler::class )
->wait_for_decision( result_key: 'review' )
->then( HandleDecisionHandler::class )
->dispatch( [ 'topic' => 'PHP middleware patterns' ] );The NotifyReviewerHandler sends an email or Slack message with approve/reject links. Those links call your controller, which sends the signal:
// In your approval controller
Queuety::approve_workflow(
$workflow_id,
[
'reviewer' => $current_user->user_email,
'reviewed_at' => time(),
],
'approved'
);The HandleDecisionHandler step reads $state['review']['outcome'] and either publishes or archives the draft.
Use case: external API callback
Wait for a webhook from an external service:
$workflow_id = Queuety::workflow( 'generate_video' )
->then( SubmitToRenderServiceHandler::class )
->wait_for_signal( 'render_complete' )
->then( DownloadVideoHandler::class )
->then( NotifyUserHandler::class )
->dispatch( [ 'template_id' => 'promo_v2', 'user_id' => 42 ] );When the external service posts to your webhook endpoint:
// In your webhook handler
$payload = json_decode( file_get_contents( 'php://input' ), true );
$workflow_id = (int) $payload['metadata']['workflow_id'];
Queuety::signal( $workflow_id, 'render_complete', [
'video_url' => $payload['output_url'],
'duration' => $payload['duration_seconds'],
] );Multiple signal gates in one workflow
A workflow can wait for signals at multiple points:
Queuety::workflow( 'multi_approval' )
->then( CreateProposalHandler::class )
->wait_for_signal( 'manager_approval' )
->then( EscalateToDirectorHandler::class )
->wait_for_signal( 'director_approval' )
->then( ExecuteProposalHandler::class )
->dispatch( [ 'proposal_id' => 7 ] );Each signal wait step is independent. You can mix wait_for_signal(), wait_for_signals(), wait_for_approval(), wait_for_input(), and wait_for_decision() in the same workflow.