Queuety

State Machines

State machines and workflows solve different problems.

A workflow answers, "what work runs next?" A state machine answers, "what state is this thing in, and what events are valid from here?" That difference matters when the thing you are building is not just a sequence of steps, but a long-lived entity that reacts to outside input over time, like an agent session, a chat thread, a review lifecycle, a ticket, or an approval flow.

Quick example

use Queuety\Enums\StateMachineStatus;
use Queuety\Queuety;

$machine_id = Queuety::machine( 'agent_session' )
    ->version( 'agent-session.v1' )
    ->state( 'awaiting_user' )
    ->on( 'user_message', 'planning' )
    ->state( 'planning' )
    ->action( PlanSessionAction::class )
    ->on( 'planned', 'awaiting_review' )
    ->state( 'awaiting_review' )
    ->on( 'approve', 'completed', ReviewApprovedGuard::class )
    ->on( 'reject', 'awaiting_user' )
    ->state( 'completed', StateMachineStatus::Completed )
    ->dispatch( [ 'thread_id' => 42 ] );

Queuety::machine_event(
    $machine_id,
    'user_message',
    [ 'message' => 'Find competitors for this product' ]
);

Queuety::machine_event(
    $machine_id,
    'approve',
    [ 'approved' => true, 'reviewer' => 'editor@example.com' ]
);

This machine starts in awaiting_user, moves into planning when a user message arrives, lets the queued action emit planned, and then waits in awaiting_review until an external approval completes the session. The important point is not the code syntax by itself. It is that the lifecycle is explicit, inspectable, and event-driven.

How a machine runs

When a machine instance is dispatched, Queuety persists the current state name, the public state payload, the definition version and hash, the valid incoming events for the current state, and a durable event timeline.

If the entered state has an action(), Queuety enqueues a state-entry job. That action can simply update public state and leave the machine waiting for the next event, or it can emit _event and optionally _event_payload to drive the next transition immediately. If the state has no action and no terminal status, the machine just waits for external events sent with Queuety::machine_event().

Choosing between workflows and state machines

Use a workflow when the run is fundamentally step-oriented. Fetch data, for each work, wait, synthesize, finish. That model is a good fit when there is a clear start and finish and the main question is what work should execute next.

Use a state machine when valid behavior depends on the current lifecycle phase. That is the better fit when events may arrive in different orders, when the entity may live for a long time, or when you need explicit lifecycle inspection with states like awaiting_user, planning, running_agents, awaiting_review, and completed.

How the two fit together

The two primitives work best together rather than competing with each other. A state action can dispatch a workflow when a transition needs heavier execution, and a workflow can emit an event back into a machine when that work is done. In practice, that lets the state machine govern the lifecycle while workflows perform the expensive or highly structured execution.

Builder shape

StateMachineBuilder stays deliberately small. initial() and state() define the lifecycle graph. action() attaches queued work to one state. on() declares which events can move the machine into the next state. on_queue(), with_priority(), and max_attempts() control how state actions are executed, while version() and idempotency_key() attach durable dispatch metadata. dispatch() creates the machine instance.

State actions can also expose an optional config() method, using the same resource-policy keys that workflow steps and handlers use. That makes it possible to keep one expensive state transition inside a shared concurrency group or to attach a higher cost weight when an action consumes more budget than a normal step.

The broader resource model is described in Resource Management.

Contracts

State-entry actions implement Queuety\Contracts\StateAction:

namespace Queuety\Contracts;

interface StateAction {
    public function handle( array $state, ?string $event = null, array $event_payload = [] ): array|string;
}

State actions may also define an optional config() method with concurrency_group, concurrency_limit, and cost_units when the queued entry action should participate in worker admission, weighted queue or provider budgets, and other resource policies.

Transition guards implement Queuety\Contracts\StateGuard:

namespace Queuety\Contracts;

interface StateGuard {
    public function allows( array $state, array $event_payload, string $event ): bool;
}

Inspection APIs

Use the facade to inspect running machines:

$status   = Queuety::machine_status( $machine_id );
$machines = Queuety::list_machines();
$events   = Queuety::machine_timeline( $machine_id );
$trace    = Queuety::machine_trace( $machine_id );
$state    = Queuety::machine_state_at( $machine_id, $event_id );

Those APIs expose the current state name, lifecycle status, public state, available events, definition metadata, the full transition timeline, and a normalized trace bundle for debugger UIs. Machine list rows include definition version, definition hash, and idempotency key so list screens can show the source/runtime version without loading each machine individually. The trace bundle includes grouped states, raw events, correlated action jobs, logs, artifact references, chunk references, handler input/output, state before/after, context, timings, and structured errors.

Agent sessions

State machines are especially useful for agent systems that behave more like sessions than pipelines. A session that moves through awaiting_user, planning, running_agents, awaiting_review, and completed is usually easier to reason about as an explicit lifecycle than as one very long workflow definition with many waits and back-edges.

See also

On this page