Skip to main content
Version: 2.0

Side Effects

A side effect is a closure containing non-deterministic code. The closure is only executed once and the result is saved. It will not execute again if the workflow is retried. Instead, it will return the saved result. This makes the workflow deterministic because replaying the workflow will always return the same stored value rather than re-running the non-deterministic code.

use function Workflow\V2\await;
use function Workflow\V2\sideEffect;
use Workflow\V2\Attributes\Signal;
use Workflow\V2\Workflow;

#[Signal('finish')]
class MyWorkflow extends Workflow
{
public function handle(): array
{
$token = sideEffect(fn () => random_int(1000, 9999));
$finish = await('finish');

return compact('token', 'finish');
}
}

The workflow will only call random_int() once and save the result, even if the workflow later fails and is retried.

When to use side effects

Use sideEffect() when you need a non-deterministic value that:

  • is computed locally without external I/O (random numbers, UUIDs, timestamps)
  • should never change once recorded, even across replays
  • does not need retry semantics — the closure runs exactly once
// Generate a correlation token for downstream systems.
$correlationId = sideEffect(fn () => (string) Str::uuid());

// Snapshot the current time for a business rule.
$decidedAt = sideEffect(fn () => now()->toIso8601String());

When to use an activity instead

If the code can fail, talks to an external service, or needs retry/timeout semantics, use an activity instead of a side effect:

ScenarioUse
Generate a random tokensideEffect()
Read a config value at decision timesideEffect()
Call an external APIactivity()
Write to a databaseactivity()
Send an email or notificationactivity()
Compute an expensive value that can throwactivity()

The rule of thumb: if the closure can throw an exception that you would want to retry, it belongs in an activity.

How it works

  • each sideEffect() call appends a typed SideEffectRecorded history event with the workflow step sequence
  • workflow replay and query replay both reuse that committed value instead of re-running the closure
  • Waterline surfaces the side-effect snapshot as a typed history entry in the selected run timeline
  • side effects are still for replay-safe snapshots only, not for work that can fail or that needs retry semantics

Anti-patterns

Do not call external services inside a side effect. If the service call fails, the side effect will not be retried and the workflow will fail permanently:

// BAD: HTTP calls can fail and side effects do not retry.
$price = sideEffect(fn () => Http::get('/api/price')->json('amount'));

// GOOD: Use an activity for external calls.
$price = activity(FetchPriceActivity::class);

Do not put slow or blocking operations inside a side effect. The closure runs on the workflow task thread. Long-running work delays the entire workflow task:

// BAD: Expensive computation blocks the workflow task.
$hash = sideEffect(fn () => bcrypt($largePayload));

// GOOD: Offload heavy work to an activity.
$hash = activity(ComputeHashActivity::class, $largePayload);

Do not rely on mutable external state. The closure is executed exactly once. If you read a value that changes over time, the snapshot is frozen at the moment of first execution — not at replay time:

// The cached value is whatever it was during the first execution.
// If the cache changes later, this workflow still sees the old value.
$setting = sideEffect(fn () => cache('feature.flag'));

This is by design — the snapshot is intentionally frozen for determinism. If you need a value that updates over the lifetime of the workflow, use a signal or an activity.