---
title: "Step handler contract — Workflows for Laravel | Roundly"
description: "Write your own step type: registration, the StepType class, ConfigSchema, StepContext, StepResult and WaitSpec, idempotency, failures and cancel hooks."
url: https://roundly-consulting.com/open-source/docs/workflows-for-laravel/step-handler-contract
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Workflows for Laravel](https://roundly-consulting.com/open-source/docs/workflows-for-laravel.md)

# 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).

```php
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

```php
// 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:

```bash
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

| Method | Required | Meaning |
| --- | --- | --- |
| `key(): string` | yes | Catalogue key; host keys must contain a dot (acme.create\_task). |
| `label(): string, description(): ?string, category(): string` | label | Translated text for the UI. description() defaults to null, category() to custom. |
| `configSchema(): array` | yes | JSON 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(): ?array` | no | Lets publish validation check steps.<node>.output.<key> refs. |
| `hasSideEffects(): bool` | yes | Side-effect types get the authorization hook, retries, idempotency keys and “would run” in dry runs. |
| `ability(): ?string` | no | Policy ability the default authorizer checks on the subject before handle(). |
| `abilityFor(StepContext $context): ?string` | no | The ability per run (default: ability()); set\_field uses the subject type’s own. |
| `retry(): ?RetryPolicy` | no | Type default retry policy. |
| `supportsEscalations(): bool` | default false | escalations on a node of another type → invalid\_config at /escalations. |
| `validateConfig(array $config, ValidationScope $scope): list<ValidationError>` | no | Semantic checks beyond the schema; build errors with ValidationError::make(…). |
| `ownerSpecificPaths(): list<string>` | no | JSON pointers cleared and flagged on template import; also derived from x-workflows-owner-specific: true. |
| `handle(StepContext $context): StepResult` | yes | The work. |
| `resume(StepContext $context, Signal $signal): StepResult` | types that wait on events or conditions | Called 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): ?StepResult` | no | Dry run. Must be pure. Absent on a side-effect type → “would run”. |
| `cancel(StepContext $context): void` | no | Clean-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(): bool` | no | The 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:

```php
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();
}
```

| Method | Property |
| --- | --- |
| `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

```php
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 / builder | Meaning |
| --- | --- |
| `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:

```php
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](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## 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.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/workflows-for-laravel.md)
