NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Jobs

Three jobs carry ids only, have tries = 1 (the engine owns retries), dispatch after commit and use workflows.queue.connection / queue. The job timeout is engine.lease − 30 s. A job’s failed() records a worker timeout only through a compare-and-set on the attempt’s claim token and an expired lease, so a redelivered job never schedules a retry next to a live handler:

JobRuns
RunStepJob(stepRunId)One attempt of a step.
ResumeStepJob(stepRunId, waitId, kind)A waiting step whose wait fired.
FireEscalationJob(escalationId)An escalation level.

One step, two transactions

  • Claim — inside the instance’s owner context: lock the instance, then the step run. Exit if the instance is no longer running/waiting or the step run is no longer queued (duplicate delivery). Mark the run running with a fresh claim_token and lease_until. Commit → StepStarted.
  • Outside the lock: check the subject’s owner (owner_mismatch → the handler never runs), run the authorization hook for side-effect types, call handle() or resume(). The DB-only built-ins (condition, branch, set_field, loop, end) run inside the record transaction instead, so the effect and the bookkeeping commit together.
  • Record — lock the instance, then the step run; the claim token must still match, else the result is discarded (a lost lease). Complete: store outcome and output, cancel this visit’s escalations, follow the edge. Wait: store the wait rows; the instance is waiting. Fail: see retries.

Lock order: instance (a parent before its children) → step run → wait / escalation. No transaction locks a second instance except parent → child (cancel cascade). Cross-instance signals are sent after commit.

Waits and workflows:wake

Wait kindFires when
timerdue_at ≤ now
eventemit() / signal() with its key and a matching subject of the same owner (and, with where, a matching payload)
conditionThe subject is touched or the poll is due, and the condition holds
retryDue → the next attempt runs
timeoutDue → resume() with a timeout signal

Claims are portable compare-and-sets (no SKIP LOCKED), in batches of wake.batch; a claim older than wake.claim_ttl reverts (a claimed event is delivered again instead). A fired wait cancels its siblings (an event cancels its timeout and vice versa); a wait whose step has moved on is cancelled. A signal that arrives while its step is running stays claimed: the running attempt delivers it as soon as the step waits again, or retires it if the step moves on — so a one-shot signal such as a child’s end is never lost.

workflows:wake, in order: deliver claimed events again whose resume job was lost → reclaim other stale claims → reclaim expired leases (the attempt fails lease_expired, retryable) → re-push lost jobs (queued runs pushed longer ago than engine.requeue_after, and the probe of a waiting step that was never claimed) → due timers, retries and timeouts → due condition polls → due escalations → schedule triggers. It returns a WakeReport (timers, retries, timeouts, polls, escalations, schedules, reclaimed, requeued). Wake never evaluates anything about a subject itself: it hands work to jobs that restore the owner context first. Workflows::wake($now) runs the whole pass at $now — what has expired, the claim stamps, which waits are due — and a retry it fires runs in that pass even when $now is ahead of the clock; workflows:wake passes no time.

Retries and failure

{ "id": "sync_erp", "type": "acme.sync_erp", "config": { },
  "retry": { "max_attempts": 5, "backoff": [10, 60, 300] } }    // the last value repeats

// in PHP: ->node('sync_erp', 'acme.sync_erp')->retry(5, [10, 60, 300])
  • Policy: node retry → settings.retry → the type’s retry() → retry.default (max_attempts: 3, backoff: [30, 120, 600] seconds, the last value repeats, jitter: 0.1). max_attempts is capped by retry.max_attempts_cap (10).
  • Retryable failure with attempts left: the run is failed; a new run (attempt + 1, the same idempotency key) is scheduled after the backoff (or retryAfter, if later). StepFailed{willRetry: true}.
  • A failed resume is retried as a resume: the next attempt calls resume() again with the same signal and the input and output the step waited with (stored in resume_signal). It never runs handle() again, so a delivered event is not lost and a timer is not restarted.
  • Permanent failure, or the last attempt: the instance is failed with failure_code, failure_message, failed_node_id; its waits and escalations are cancelled; StepFailed, InstanceFailed.
  • Never retried automatically: authorization_denied, owner_mismatch, subject_missing, invalid_output, every limit (except a start_workflow child the start-rate limit refused: that step or escalation retries as child_not_started), no_approvers, transition_denied (unless lifecycle says retryable), StepFailedException, and any package exception a handler lets escape (it fails the step with its own error code). Unknown exceptions are retried as exception.
  • Manual retry, Workflows::instances()->retry($instance): a failed instance whose last step failed gets attempt n + 1 (same key) and resumes from that node. A step whose handle() failed runs handle() again (approvals re-read their round); a step whose resume() failed is resumed again with the stored signal — the event’s key and payload, the decision, the timeout — and the input and output it waited with, exactly like the automatic retry, so an event that already arrived is not waited for again. Not capped; InstanceRetried. Otherwise InstanceNotRetryableException — for a failed instance that did not fail on a step, its message says to start it again instead.
  • Cancel, Workflows::instances()->cancel($instance, $reason): a running or waiting instance becomes cancelled. Its waits and escalations are cancelled first, then the live step’s cancel() hook runs, then children started with wait: true are cancelled. A handler that finishes after the cancel has its result discarded, and the step’s cancel() hook runs again. A terminal instance throws InstanceNotCancellableException.

Idempotency

  • One ledger (workflow_idempotency_keys, digests only), two scopes: start (definition + trigger key — a repeated key returns the first instance with created: false) and effect (step idempotency key + once() name — a repeated once() returns the stored result).
  • The start key is checked under the definition lock and by a unique index, in its own savepoint: parallel duplicates on PostgreSQL and MySQL return the same instance.
  • Trigger start keys expire after idempotency.ttl (P30D) and are pruned; a key re-used later starts a new instance. Effect keys and engine-internal start keys live as long as their instance, so a manual retry months later still repeats nothing.

Limits

LimitConfig (default)Failure
Steps per instancelimits.max_steps (200); settings.limits.max_steps may lower itInstance fails step_limit_exceeded.
Nesting depthlimits.max_depth (3; root = 0)The parent step fails nesting_depth_exceeded.
Starts per definition per minutelimits.max_starts_per_minute (60; off = none)The instance is created failed with start_rate_exceeded; the start returns created: false, refused, and its idempotency key stays free (the webhook route answers 429; a start_workflow step or escalation retries with backoff as child_not_started).
Loop iterationslimits.max_loop_iterations (1000)invalid_config at publish.
Graph sizegraph.max_nodes / max_edges / max_bytesgraph_too_large at publish.
Step outputsteps_output.max_bytes (64 KiB)The attempt fails invalid_output.

Every failure message is translated and names the limit and its value.

Actor and authorization

  • settings.actor: system (default) or on_behalf_of the triggering user. An on-behalf-of start without a user throws MissingTriggeringUserException; schedule and webhook triggers can’t use it (actor_requires_user).
  • The authorization hook (Contracts\AuthorizesSteps, workflows.authorization.authorizer) runs before every side-effect step and before each escalation action — always, also when the step names no ability. The ability is workflows.authorization.abilities.<type key>, else the type’s ability(), else null. Graph config can’t choose the ability.
  • The default GateStepAuthorizer checks Gate::forUser($user)->inspect($ability, [$subject]) and allows a null ability (the target package enforces its own access); a hook you bind yourself receives every call, the null-ability ones included, and its denial fails the step.
  • The system actor authorizes as workflows.actor.system_user (an invokable returning a service account), else as a null user — Laravel policies deny a null user unless they accept ?User. Fail closed.
  • A denial fails the step authorization_denied with the policy’s message; never retried. A denied escalation action fails the escalation, never the step.
  • Lifecycle transitions apply as the user (by()) or the system (asSystem()), so lifecycle’s own actor rules apply on top. Every instance event carries the instance as $actor and the Actor DTO as $runAs.
use Illuminate\Auth\Access\Response;
use RoundlyConsulting\Workflows\Contracts\AuthorizesSteps;
use RoundlyConsulting\Workflows\DataTransferObjects\StepAuthorization;
use RoundlyConsulting\Workflows\Support\GateStepAuthorizer;

final readonly class WorkflowStepAuthorizer implements AuthorizesSteps
{
    public function __construct(private GateStepAuthorizer $gate) {}

    public function authorize(StepAuthorization $authorization): Response
    {
        // Called before every side-effect step and escalation action, also when the ability is null.
        if ($authorization->instance->owner?->suspended_at !== null) {
            return Response::deny('This team is suspended.');
        }

        return $this->gate->authorize($authorization);
    }
}

// config/workflows.php
'authorization' => [
    'authorizer' => App\Workflows\WorkflowStepAuthorizer::class,
    'abilities' => ['set_field' => 'updatePriority'],   // step type key => policy ability
],

Owner context

Contracts\RestoresOwnerContext::run(?Model $owner, Closure) wraps every job, the start transaction, the emit fan-out, retry, cancel and dry runs. With teams installed and a team owner it is Teams::runAs($team, …); otherwise the callback just runs (workflows.owners.context names your own for other tenancy). A job dispatched from a request in team B for an instance of team A runs as A. The subject is loaded inside the context, so a team-scoped subject of another team reads as missing (subject_missing).

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.