---
title: "The engine — Workflows for Laravel | Roundly"
description: "How steps run: jobs and leases, two transactions per step, waits and workflows:wake, retries, idempotency, limits, authorization and owner context."
url: https://roundly-consulting.com/open-source/docs/workflows-for-laravel/engine
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)

# The engine

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

| Job | Runs |
| --- | --- |
| `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 kind | Fires when |
| --- | --- |
| `timer` | due\_at ≤ now |
| `event` | emit() / signal() with its key and a matching subject of the same owner (and, with where, a matching payload) |
| `condition` | The subject is touched or the poll is due, and the condition holds |
| `retry` | Due → the next attempt runs |
| `timeout` | Due → 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

```jsonc
{ "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

| Limit | Config (default) | Failure |
| --- | --- | --- |
| Steps per instance | limits.max\_steps (200); settings.limits.max\_steps may lower it | Instance fails step\_limit\_exceeded. |
| Nesting depth | limits.max\_depth (3; root = 0) | The parent step fails nesting\_depth\_exceeded. |
| Starts per definition per minute | limits.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 iterations | limits.max\_loop\_iterations (1000) | invalid\_config at publish. |
| Graph size | graph.max\_nodes / max\_edges / max\_bytes | graph\_too\_large at publish. |
| Step output | steps\_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.

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