Loops
Queuety workflows are ordered step chains. Use a loop control step to revisit an earlier named step instead of hand-writing _goto logic.
Choose the form that matches the workflow:
- a public
state_keywhen the condition is simple - a
condition_classwhen the loop needs richer logic max_iterationswhen the loop itself needs a hard stop
Both methods target an earlier named step, so the loop stays serializable, replayable, and guarded by the same workflow runtime as every other step.
The loop condition is evaluated from the persisted public workflow state after the preceding step completes, so every iteration stays durable across worker restarts.
repeat_until() with a state key
Queuety::workflow( 'approval_revision_loop' )
->max_transitions( 12 )
->then( DraftBriefStep::class, 'draft' )
->await_decision( result_key: 'review' )
->then( NormalizeReviewOutcomeStep::class, 'review_outcome' )
->repeat_until( 'draft', 'review_approved', true, 'repeat_draft_until_approved' )
->then( PublishBriefStep::class )
->dispatch( [ 'brief_id' => 42 ] );NormalizeReviewOutcomeStep can flatten the decision payload into a simple public key:
class NormalizeReviewOutcomeStep implements \Queuety\Step {
public function handle( array $state ): array {
return [
'review_approved' => 'approved' === ( $state['review']['outcome'] ?? null ),
];
}
public function config(): array {
return [];
}
}If review_approved is still not true, the loop step jumps back to draft. Once it becomes true, the workflow falls through to PublishBriefStep.
repeat_until() with a condition class
use App\Workflows\ReviewApprovedCondition;
Queuety::workflow( 'approval_revision_loop' )
->then( DraftBriefStep::class, 'draft' )
->await_decision( result_key: 'review' )
->repeat_until(
target_step: 'draft',
condition_class: ReviewApprovedCondition::class,
max_iterations: 5,
name: 'repeat_draft_until_approved',
)
->then( PublishBriefStep::class )
->dispatch( [ 'brief_id' => 42 ] );namespace App\Workflows;
use Queuety\Contracts\LoopCondition;
final class ReviewApprovedCondition implements LoopCondition {
public function matches( array $state ): bool {
return 'approved' === ( $state['review']['outcome'] ?? null );
}
}Condition classes receive the public workflow state, so the predicate stays serializable and inspectable without relying on closures.
repeat_while()
Queuety::workflow( 'poll_remote_status' )
->max_transitions( 20 )
->then( PollRemoteStatusStep::class, 'poll' )
->sleep( seconds: 30 )
->repeat_while( 'poll', 'should_poll_again', true, 'repeat_poll' )
->then( FinalizeImportStep::class )
->dispatch( [ 'import_id' => 99 ] );This pattern is useful when a step writes a simple boolean such as should_poll_again, has_more_pages, or needs_revision.
Why loops are separate from _goto
You can still branch manually by returning _goto from a normal step. The loop helpers exist for the narrower case where:
- the target is an earlier step
- the condition comes from public workflow state
- you want the loop logic to stay visible in the workflow definition itself
That makes loops easier to inspect in exports, event logs, and docs than burying the back-edge inside arbitrary step code.
Guarding loops
Use max_iterations when one specific loop needs a local hard stop:
Queuety::workflow( 'poll_remote_status' )
->then( PollRemoteStatusStep::class, 'poll' )
->repeat_while( 'poll', 'should_poll_again', true, max_iterations: 8 )
->dispatch();Use max_transitions() when the entire workflow needs a broader dead-man switch:
Queuety::workflow( 'safe_revision_loop' )
->max_transitions( 10 )
->then( DraftBriefStep::class, 'draft' )
->await_decision( result_key: 'review' )
->then( NormalizeReviewOutcomeStep::class )
->repeat_until( 'draft', 'review_approved', true )
->dispatch();If a loop exceeds max_iterations, or the workflow exceeds max_transitions(), Queuety fails the run instead of letting it spin forever.