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

Step handler contract

A step type is a class extending RoundlyConsulting\Workflows\Steps\StepType. It is both the catalogue entry (what a builder shows and validates) and the handler (what runs).

use RoundlyConsulting\Workflows\Steps\Schema\ConfigSchema;
use RoundlyConsulting\Workflows\Steps\StepContext;
use RoundlyConsulting\Workflows\Steps\StepResult;
use RoundlyConsulting\Workflows\Steps\StepType;

final class CreateTaskStep extends StepType
{
    public function key(): string { return 'acme.create_task'; }
    public function label(): string { return __('acme.workflows.create_task'); }
    public function hasSideEffects(): bool { return true; }
    public function ability(): ?string { return 'createTask'; }

    public function configSchema(): array
    {
        return ConfigSchema::object()->string('title', required: true, maxLength: 200)->toArray();
    }

    public function handle(StepContext $context): StepResult
    {
        $taskId = $context->once('task', fn (): int => Task::create([
            'title' => $context->config()['title'],
            'subject_id' => $context->subject()?->getKey(),
        ])->id);

        return StepResult::complete(output: ['task_id' => $taskId]);
    }

    public function simulate(StepContext $context): StepResult
    {
        return StepResult::complete(output: ['task_id' => null])
            ->wouldDo('Create task "'.$context->config()['title'].'"');
    }
}

Registration

// config/workflows.php
'steps' => [App\Workflows\CreateTaskStep::class],

// or at runtime, in a service provider
Workflows::catalogue()->register(CreateTaskStep::class);
  • Host keys must contain a dot (acme.create_task); un-dotted keys are reserved for the package: trigger, condition, branch, set_field, transition, delay, wait, loop, start_workflow, end, approval, notify, webhook.
  • Registering the same class twice is a no-op; another class with the same key throws DuplicateStepTypeException. A bad key or an invalid config schema throws InvalidStepTypeException at registration (unsupported JSON-schema keywords, remote $refs and invalid patterns are refused there, naming the keyword).
  • The catalogue is resolved lazily, so host providers that boot after the package still register in time.

Generate a starting point:

php artisan make:workflow-step CreateTicketStep
# app/Workflows/Steps/CreateTicketStep.php — key app.create_ticket, a label, side effects on,
# ability() returning null, a configSchema() with one required title string and a handle()
# that completes with empty output; register it with Workflows::catalogue()->register()

The generated class is simpler than the example above: its handle() carries a comment pointing at once() and idempotencyKey(), and it has no simulate(). Add the once(), simulate() and ability() parts from the example yourself.

The class

MethodRequiredMeaning
key(): stringyesCatalogue key; host keys must contain a dot (acme.create_task).
label(): string, description(): ?string, category(): stringlabelTranslated text for the UI. description() defaults to null, category() to custom.
configSchema(): arrayyesJSON Schema subset; #/$defs/{value, party, parties, duration, condition, field} are provided.
outcomes(array $config): list<Outcome>default [Outcome::next()]Outcome::of($key, required: true, label: null). May depend on config.
outputSchema(): ?arraynoLets publish validation check steps.<node>.output.<key> refs.
hasSideEffects(): boolyesSide-effect types get the authorization hook, retries, idempotency keys and “would run” in dry runs.
ability(): ?stringnoPolicy ability the default authorizer checks on the subject before handle().
abilityFor(StepContext $context): ?stringnoThe ability per run (default: ability()); set_field uses the subject type’s own.
retry(): ?RetryPolicynoType default retry policy.
supportsEscalations(): booldefault falseescalations on a node of another type → invalid_config at /escalations.
validateConfig(array $config, ValidationScope $scope): list<ValidationError>noSemantic checks beyond the schema; build errors with ValidationError::make(…).
ownerSpecificPaths(): list<string>noJSON pointers cleared and flagged on template import; also derived from x-workflows-owner-specific: true.
handle(StepContext $context): StepResultyesThe work.
resume(StepContext $context, Signal $signal): StepResulttypes that wait on events or conditionsCalled when a wait fires. The default completes a timer with next and a timeout with its outcome; any other signal fails resume_unsupported.
simulate(StepContext $context): ?StepResultnoDry run. Must be pure. Absent on a side-effect type → “would run”.
cancel(StepContext $context): voidnoClean-up when the instance is cancelled while this step is live (waiting, queued for a retry or running). Make it idempotent.
reassign(StepContext $context, list<PartyReference> $to): ?StepResult + supportsReassign(): boolnoThe reassign escalation action (default: none; publish refuses reassign on a type without it).

cancel() details: an attempt that was running is cleaned up twice — once by the cancel (without its output) and once more when its result is recorded and discarded, with the output it returned, or, after a failed attempt or a worker that died, with none: read what once() committed with $context->recall().

Writing the config schema

ConfigSchema writes a schema with only the supported keywords, so a built schema always registers. Every method takes ?string $description and ?string $label (the title keyword, for builder forms). The schema is immutable — each call returns a new one:

use RoundlyConsulting\Workflows\Steps\Schema\ConfigSchema;

public function configSchema(): array
{
    return ConfigSchema::object()                                       // additionalProperties: false (object(true) allows more)
        ->string('title', required: true, maxLength: 200, label: 'Title')
        ->party('assignee', ownerSpecific: true)                        // flagged for review on template import
        ->enum('priority', ['low', 'normal', 'high'], default: 'normal')
        ->integer('estimate', min: 1, max: 40)
        ->list('tags', 'string', maxItems: 10)
        ->nested('meta', ConfigSchema::object()->string('source'))
        ->duration('due_in')
        ->nodeId('after')                                               // another node's id, remapped on import
        ->toArray();
}
MethodProperty
string($name, required, minLength, maxLength, pattern, format, description, label){type: string, …}
integer($name, required, min, max, …), number(…), boolean($name, required, default, …){type: integer|number|boolean, minimum?, maximum?}
enum($name, list $values, required, default, …){enum, default?}
list($name, ConfigSchema|string $items, required, minItems, maxItems, …){type: array, items} (a string is a JSON type)
nested($name, ConfigSchema $schema, required, …)A nested object (object() is the static entry point, so this one is nested()).
value, party(…, ownerSpecific), parties(…, ownerSpecific), duration, condition, field{$ref: "#/$defs/<name>"} (+ x-workflows-owner-specific: true)
nodeId($name, …){type: string, format: node-id}

What the engine guarantees

  • One instance never advances twice in parallel. A handler never runs concurrently with another step of the same instance, as long as the worker enforces the job timeout (engine.lease − 30 s; needs pcntl) and the queue connection’s retry_after is longer than that timeout. Without pcntl a handler that outlives its lease can overlap its own retry. workflows:doctor checks both.
  • $context->idempotencyKey() is stable across every attempt of the same visit: wf:{instance ulid}:{node id}:{visit} (≤ 100 chars, not a secret). It differs per visit, so a loop iteration is a new effect.
  • Before handle(): the subject is loaded inside the instance’s owner context and checked against the owner; side-effect types passed the authorization hook; limits were checked.

What a handler must do

  • Make every side effect idempotent on that key. For database effects use $context->once($name, Closure): it runs the closure and records its result in the same transaction, so a retry returns the stored result. Or store the key in a unique column. For external effects pass the key on (an HTTP Idempotency-Key, Notifications::send(idempotencyKey:), lifecycle’s idempotencyKey()). once() is exactly-once only for writes on the same database connection.
  • $context->recall($name) reads what once($name) recorded for the visit without running anything (null if nothing) — for a cancel() hook whose attempt failed before it returned its output.
  • Never sleep, poll or block. To wait, return StepResult::wait(WaitSpec).
  • Keep no state between calls. Everything durable goes in output or in your own tables.
  • Return plain-JSON output (scalars, lists, maps) ≤ steps_output.max_bytes (64 KiB). Models, objects, closures, INF/NAN → the attempt fails invalid_output (not retried).
  • Authorize anything beyond the type’s ability() with $context->authorize($ability, $arguments). A denial fails the step authorization_denied (not retried).

Signalling failure

use RoundlyConsulting\Workflows\Steps\{RetryableStepException, StepFailedException};

throw StepFailedException::because('acme.task_board_closed', 'The task board is closed.');      // permanent
throw RetryableStepException::because('acme.rate_limited', 'Try again later.', now()->addMinute()); // retried

$context->authorize('assignTask', [$assignee]);   // a denial fails the step authorization_denied (never retried)
  • StepFailedException::because($code, $message) — permanent.
  • RetryableStepException::because($code, $message, ?CarbonInterface $retryAfter) — retried.
  • Any other Throwable — retried, with error_code = exception. Its message is stored for operators (error_message, the step log in PHP, workflows:retry) but never rendered over the wire: the resources show the translated workflows::failures.exception instead, because framework messages routinely carry class names.
  • Both because() methods take a FailureCode case or your own string code; your codes stay plain strings in WorkflowInstance::failure() / StepRun::error(). failureCode() and RetryableStepException::retryAfter() expose what the engine records.

StepResult and WaitSpec

Constructor / builderMeaning
StepResult::complete(string $outcome = 'next', array $output = [])Done; follow the edge for $outcome.
StepResult::wait(WaitSpec $spec, array $output = [])Park the step until the wait fires.
StepResult::fail(string $code, string $message)Permanent failure (same as StepFailedException).
->wouldDo(string $description, array $details = [])In simulate(): an action a dry run lists.
WaitSpec::until(CarbonInterface $at)Fires at a time.
WaitSpec::event(string $key, array $match = [])Fires on emit()/signal() of the key.
WaitSpec::condition(array $condition, ?Duration $poll = null)Fires when the condition holds (on subject touch or poll).
->timeout(Duration|CarbonInterface $after, string $outcome = 'timed_out')Resumes with a timeout signal.
->probe()Resumes once right after commit (for waits that may already be satisfied).
->alsoAt(CarbonInterface $at)An extra timer (a safety net).

StepResult is final readonly with isComplete(), isWait() and isFail(). A waiting step type — here one that sends a contract for e-signature, waits for the signed event and gives up after five days:

use RoundlyConsulting\Workflows\DataTransferObjects\Signal;
use RoundlyConsulting\Workflows\Enums\SignalKind;
use RoundlyConsulting\Workflows\Steps\{Outcome, StepContext, StepResult, WaitSpec};

public function outcomes(array $config): array
{
    return [Outcome::of('signed'), Outcome::of('timed_out')];
}

public function supportsEscalations(): bool { return true; }

public function handle(StepContext $context): StepResult
{
    $envelope = $context->once('envelope', fn (): string => $this->esign->send($context->subject(), $context->idempotencyKey()));

    return StepResult::wait(
        WaitSpec::event('contract.signed')->timeout($context->now()->addDays(5)),
        output: ['envelope' => $envelope],
    );
}

public function resume(StepContext $context, Signal $signal): StepResult
{
    if ($signal->kind === SignalKind::Event) {
        return StepResult::complete('signed', output: ['signed_by' => $signal->payload['by'] ?? null]);
    }

    return parent::resume($context, $signal);   // a timer completes with next, a timeout with timed_out
}

A step that uses ->probe() must re-read its source of truth in resume() (the child’s status, the round’s state), never trust the signal kind alone. A signal that lands while the probe runs stays claimed and is delivered afterwards, but a probe that fails transiently is retried as a probe — reading the source again is what makes both paths reach the same answer.

StepContext

  • Reads: instance(), stepRun(), node(), graph(), version(), definitionKey(), config() (schema-validated), subject(), owner(), actor(), input(), trigger(), output(string $nodeId), resolve(mixed $valueOrRef), field(string $path), parties(list<PartyReference>), idempotencyKey(), attempt(), visit(), now(), timezone(), calendar(?string $name), defaultCalendar(), signal() (in resume()), simulating().
  • Effects: authorize(string $ability, array $arguments = []) (skipped while simulating) and once(string $name, Closure $callback) (returns null while simulating); recall(string $name) reads a recorded once() result without running anything (null when none, or while simulating).
  • The recorded input: before the handler runs, each attempt records step_runs.input — the node config with every value reference resolved at that moment: literals verbatim, refs and relatives resolved, models as {id, type}, field and triggered_by parties with the resolved person’s {id, type} added (role parties stay as written). Over steps_input.max_bytes it is {"truncated": true, "bytes": n}. A retry records its own input; a resume keeps it. Keep secrets out of node configs and refs: the input is stored and rendered by StepRunResource.

ValidationScope

What validateConfig() checks against (final readonly): graph, node, subjectType and ownerType (?SubjectTypeDefinition), owner, inputs (declared trigger inputs, name → FieldType), definitionKey; forNode(Node $node) and isAncestor(string $nodeId) — whether that node runs before this one on every path, so its output may be referenced. Build errors with ValidationError::make(ValidationCode, node:, path:, params:).

The config schema subset

Draft 2020-12, so a UI can feed the exported schemas to any JSON-schema form renderer. Errors come back as ValidationError(code: invalid_config, node, path: JSON pointer, params: {keyword, expected}). Depth and total checks are bounded, so a pathological schema can’t hang publish:

  • type (one or a list), enum, const.
  • properties, required, additionalProperties (bool or schema), items, minItems, maxItems, uniqueItems.
  • minLength, maxLength, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf.
  • pattern (host-authored; compiled once, length-guarded).
  • oneOf, anyOf, allOf, $defs, local $ref (#/$defs/… only).
  • format: date, date-time, duration, timezone, cron, field-path, node-id.
  • Carried through untouched: title, description, default, examples, x-workflows-* (x-workflows-field-types, x-workflows-owner-specific).

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.