Versioning
Since workflows can run for long periods, sometimes months or even years, it's common to need to make changes to a workflow definition while executions are still in progress. Without versioning, modifying workflow code that affects the execution path would cause non-determinism errors during replay.
The getVersion() helper function allows you to safely introduce changes to running workflows by creating versioned branch points.
Using getVersion
getVersion() is a replay-safe straight-line helper. Each change point records a durable version marker for the run on first execution, and every later replay reuses that committed value instead of recalculating the branch from today's code. Old runs that predate a newly introduced branch point conservatively receive DEFAULT_VERSION.
use Workflow\V2\Workflow;
use Workflow\V2\WorkflowStub;
use function Workflow\V2\{activity, getVersion};
final class MyWorkflow extends Workflow
{
public function handle(): void
{
$version = getVersion(
'my-change-id',
WorkflowStub::DEFAULT_VERSION,
1
);
if ($version === WorkflowStub::DEFAULT_VERSION) {
activity(OldActivity::class);
} else {
activity(NewActivity::class);
}
}
}
How It Works
The getVersion() method takes three parameters:
- changeId - A unique identifier for this change point
- minSupported - The minimum version this code still supports
- maxSupported - The maximum (current) version for new executions
When a workflow encounters getVersion():
- New executions append a typed
VersionMarkerRecordedhistory event with thechange_id, chosenversion, and supported range, then returnmaxSupported - Replaying executions return the previously recorded version from that history marker
- Runs without a marker yet fall back to
WorkflowStub::DEFAULT_VERSIONwhen the runtime determines the branch predates the current workflow definition. It first checks the run's compatibility marker against the current worker, then compares the run's durably snappedworkflow_definition_fingerprintto the current loadable workflow class. If the fingerprints match, the run is on the same definition and the marker is recorded fresh; if they differ, the run predates the change and the fallback fires. Runs whoseWorkflowStartedhistory predates the fingerprint snapshot (no recorded fingerprint) conservatively receiveDEFAULT_VERSIONbecause the runtime cannot prove the definition has not changed. This conservative fallback is the frozen 2.0 policy for pre-fingerprint runs: the runtime does not block claim solely because an older run lacks a recorded fingerprint. The fallback does not append a synthetic marker or consume a new workflow step - Query methods replay the same committed version marker before invoking the annotated method, so queries see the same branch the workflow task saw
- Waterline exposes recorded markers in the selected-run timeline with
version_change_id,version,version_min_supported, andversion_max_supported, and selected-run detail now also surfacesworkflow_definition_fingerprint,workflow_definition_current_fingerprint, andworkflow_definition_matches_currentso operators can see when a long-lived run started on an older definition even before a version marker was committed
This allows new workflows to use the latest code path while existing workflows continue using their original path.
Adding a New Version
Suppose you have an existing workflow that calls prePatchActivity:
use Workflow\V2\Workflow;
use function Workflow\V2\activity;
final class MyWorkflow extends Workflow
{
public function handle()
{
$result = activity(PrePatchActivity::class);
return $result;
}
}
To replace it with postPatchActivity without breaking running workflows:
use Workflow\V2\Workflow;
use Workflow\V2\WorkflowStub;
use function Workflow\V2\{activity, getVersion};
final class MyWorkflow extends Workflow
{
public function handle()
{
$version = getVersion(
'activity-change',
WorkflowStub::DEFAULT_VERSION,
1
);
$result = $version === WorkflowStub::DEFAULT_VERSION
? activity(PrePatchActivity::class)
: activity(PostPatchActivity::class);
return $result;
}
}
When you roll out a deployment that introduces a new getVersion() branch point, keep rotating DW_V2_CURRENT_COMPATIBILITY for that build wave. The runtime uses the run's start-time workflow_definition_fingerprint as its primary authority when deciding whether a missing version marker belongs to a fresh execution or to an older run that should stay on DEFAULT_VERSION. The compatibility marker still matters for compatibility-aware worker routing and fires before the fingerprint check. Runs that predate the fingerprint snapshot (no recorded fingerprint) conservatively stay on DEFAULT_VERSION since the runtime cannot verify the definition has not changed.
Adding More Versions
When you need to make additional changes, increment maxSupported:
$version = getVersion(
'activity-change',
WorkflowStub::DEFAULT_VERSION,
2
);
$result = match($version) {
WorkflowStub::DEFAULT_VERSION => activity(PrePatchActivity::class),
1 => activity(PostPatchActivity::class),
2 => activity(AnotherPatchActivity::class),
};
Deprecating Old Versions
After all workflows using an old version have completed, you can drop support by increasing minSupported. This removes the need to maintain old code paths.
// After all DEFAULT_VERSION workflows have completed:
$version = getVersion(
'activity-change',
1, // No longer supporting DEFAULT_VERSION
2
);
$result = match($version) {
1 => activity(PostPatchActivity::class),
2 => activity(AnotherPatchActivity::class),
};
If a workflow with a version older than minSupported tries to replay, it will throw a VersionNotSupportedException. That includes older-compatibility runs whose safe fallback is still WorkflowStub::DEFAULT_VERSION.
If you accidentally reuse the same changeId for a different branch point later, the runtime treats that as a determinism error instead of silently accepting the mismatch. Keep one stable changeId per logical code change.
Multiple Change Points
You can use multiple getVersion() calls in the same workflow for independent changes:
use Workflow\V2\Workflow;
use Workflow\V2\WorkflowStub;
use function Workflow\V2\getVersion;
final class MyWorkflow extends Workflow
{
public function handle(): void
{
$version1 = getVersion('change-1', WorkflowStub::DEFAULT_VERSION, 1);
$version2 = getVersion('change-2', WorkflowStub::DEFAULT_VERSION, 1);
// Each change point is tracked independently
}
}
Important: Each changeId should be unique within a workflow. The chosen version is recorded as typed workflow history and replayed deterministically on later workflow tasks, queries, and Waterline detail views. When Waterline shows no VersionMarkerRecorded entry for a change point on an older run, that means replay stayed on the legacy DEFAULT_VERSION path without backfilling a new marker into existing history. The selected-run detail fields workflow_definition_fingerprint, workflow_definition_current_fingerprint, and workflow_definition_matches_current tell you whether that run started on a different workflow definition than the one your current build can load today.
patched() Shorthand
patched($changeId) is a two-state shorthand for the common "did this run cross a one-time code change?" question. It records the same durable VersionMarkerRecorded history event as getVersion() and resolves to a boolean instead of an integer.
use Workflow\V2\Workflow;
use function Workflow\V2\{activity, patched};
final class MyWorkflow extends Workflow
{
public function handle(): string
{
if (patched('use-new-payment-activity')) {
return activity(NewPaymentActivity::class);
}
return activity(LegacyPaymentActivity::class);
}
}
Behavior:
- New runs commit the marker, take the new branch, and
patched()returnstrue. - Runs that started before this
changeIdwas added stay onDEFAULT_VERSIONunder the same fingerprint fallbackgetVersion()uses, andpatched()returnsfalse. - Replays of either kind read the previously committed marker and return the same value, so the branch decision is durable.
patched() is exactly equivalent to getVersion($changeId, DEFAULT_VERSION, 1) === 1. Use it when you only have two branches and you do not need to keep the legacy branch around forever — the boolean spelling reads cleaner at the call site than a match over DEFAULT_VERSION and 1.
If you need more than two branches, or you plan to add another version of the same logical change later, use getVersion() with an explicit maxSupported. Switching from patched() to getVersion() for the same changeId is a determinism error because the existing marker was recorded with maxSupported = 1.
Removing the Legacy Branch with deprecatePatch()
When every legacy run for a patched() change point has finished, you can delete the legacy branch from your workflow code. The compatible way to do that is to replace the patched() call with deprecatePatch() rather than removing the call entirely.
use Workflow\V2\Workflow;
use function Workflow\V2\{activity, deprecatePatch};
final class MyWorkflow extends Workflow
{
public function handle(): string
{
deprecatePatch('use-new-payment-activity');
return activity(NewPaymentActivity::class);
}
}
deprecatePatch() keeps the change point on the workflow timeline so already-committed VersionMarkerRecorded history events still match a known call, but it returns null and unconditionally takes the new path. Once you ship this version:
- New runs commit a
deprecate_patchmarker, take the new branch, and thedeprecatePatch()call returnsnull. - Existing runs that already committed a
patchedmarker for thischangeIdkeep replaying that marker and resolve through the deprecated branch — which is now the only branch — without erroring. - Existing runs that committed
falseforpatched()(the legacy path) are no longer in flight by assumption. If one is still running and tries to replay, the workflow will still read the legacy marker but execute the new branch — which is why the safe lifecycle is "wait for legacy runs to drain before deployingdeprecatePatch()".
Two-phase patch lifecycle and placement rules:
- Introduce
patched()at the change point. Both branches stay in the workflow code. New runs take the new branch; older runs keep taking the legacy branch. - Wait for every legacy run to finish. Use Waterline run search or
dw workflow:listto confirm there are no open runs that could replay through the legacy branch. - Replace
patched()withdeprecatePatch()at the same call site, with the samechangeId, and delete the legacy branch from the function body. Keep the call there — do not remove it — so durable history still matches a known change point. - Optional: remove
deprecatePatch()entirely once you are also confident no historical replay (queries, exports, audits) will ever read history that committed this marker. That is an irreversible step; most workflows can leavedeprecatePatch()in place indefinitely without paying any runtime cost.
Placement rules:
patched()anddeprecatePatch()must be called from the workflow function body, not from activities, signal handlers, or query methods. They are workflow steps and must replay deterministically.- Each
changeIdis one logical change point. Reusing the samechangeIdfor a different branch is a determinism error. - A
changeIdcan appear inpatched()ordeprecatePatch()form during its lifetime, but never both at the same time. The deploy that swaps the call must replace, not coexist. getVersion()andpatched()cannot coexist for the samechangeId. Pick one model when you introduce the change point.