Skip to main content
Version: 2.0 prerelease

Schedules

Schedules let you start workflow runs on a recurring basis using cron expressions. Each schedule is a named, durable entity that the engine evaluates on every tick to determine whether a new run should be triggered.

Creating a schedule

Use ScheduleManager::create() to define a named schedule:

use Workflow\V2\Enums\ScheduleOverlapPolicy;
use Workflow\V2\Support\ScheduleManager;

$schedule = ScheduleManager::create(
scheduleId: 'daily-invoice-sync',
workflowClass: InvoiceSyncWorkflow::class,
cronExpression: '0 2 * * *',
arguments: ['nightly'],
timezone: 'America/New_York',
overlapPolicy: ScheduleOverlapPolicy::Skip,
labels: ['team' => 'billing'],
memo: ['origin' => 'scheduled'],
searchAttributes: ['tenant_id' => '42'],
notes: 'Runs every night at 2 AM ET.',
);

The scheduleId is a unique, user-chosen identifier for the schedule. Each triggered run gets a deterministic workflow instance ID derived from the schedule ID and trigger timestamp.

Parameters

ParameterTypeDefaultDescription
scheduleIdstringrequiredUnique identifier for the schedule
workflowClassstringrequiredThe workflow class to start
cronExpressionstringrequiredStandard cron expression (5 fields)
argumentsarray[]Arguments passed to the workflow's handle() method
timezonestring'UTC'Timezone for evaluating the cron expression
overlapPolicyScheduleOverlapPolicySkipWhat to do when the previous run is still active
labelsarray[]Visibility labels applied to each triggered run
memoarray[]Memo fields applied to each triggered run
searchAttributesarray[]Search attributes applied to each triggered run
jitterSecondsint0Maximum random delay in seconds added to each fire time (thundering-herd mitigation)
maxRunsint\|nullnullMaximum number of runs before auto-deleting the schedule
connectionstring\|nullnullQueue connection for triggered runs (overrides the workflow class default)
queuestring\|nullnullQueue name for triggered runs (overrides the workflow class default)
notesstring\|nullnullFree-form operator notes
namespacestring\|nullnullNamespace for the schedule (defaults to the configured workflows.v2.namespace or 'default')

Advanced scheduling

The sections below cover scheduling features you will reach for when the basic cron pattern is not enough: fixed-interval firing, mixing cron and interval specs, and overlap policies. Skip ahead unless you need one.

Interval-based schedules

In addition to cron expressions, schedules support interval-based firing using ISO 8601 duration syntax. Use ScheduleManager::createFromSpec() for full control over the schedule spec:

use Workflow\V2\Support\ScheduleManager;

$schedule = ScheduleManager::createFromSpec(
scheduleId: 'health-check-30m',
spec: [
'intervals' => [
['every' => 'PT30M'],
],
],
action: [
'workflow_type' => 'health-check',
'workflow_class' => HealthCheckWorkflow::class,
'input' => ['region' => 'us-east-1'],
],
);

Interval spec fields

FieldTypeDescription
everystringISO 8601 duration (e.g., PT30M for 30 minutes, PT1H for 1 hour, P1D for 1 day)
offsetstring\|nullPhase offset as ISO 8601 duration — shifts the alignment point of the interval

The offset parameter controls where in the interval cycle the schedule fires. For example, an hourly interval with a 5-minute offset fires at :05, :05+1h, etc.:

$schedule = ScheduleManager::createFromSpec(
scheduleId: 'offset-hourly',
spec: [
'intervals' => [
['every' => 'PT1H', 'offset' => 'PT5M'],
],
],
action: [
'workflow_type' => 'sync-workflow',
'workflow_class' => SyncWorkflow::class,
'input' => [],
],
);

Mixed cron and interval specs

A single schedule can combine cron expressions and intervals. The engine evaluates all specs and uses the earliest upcoming fire time:

$schedule = ScheduleManager::createFromSpec(
scheduleId: 'mixed-schedule',
spec: [
'cron_expressions' => ['0 12 * * *'], // noon daily
'intervals' => [['every' => 'PT6H']], // every 6 hours
'timezone' => 'America/Chicago',
],
action: [
'workflow_type' => 'report-workflow',
'workflow_class' => ReportWorkflow::class,
'input' => [],
],
);

The createFromSpec method accepts all the same lifecycle parameters as create() (overlapPolicy, jitterSeconds, maxRuns, connection, queue, namespace, etc.) as separate named arguments.

Overlap policies

When a schedule fires and the previous run is still active, the overlap policy controls behavior:

PolicyBehavior
SkipDo not start a new run (default)
BufferOneBuffer one pending trigger; skip further triggers until the buffer is drained. On the next tick() after the active run completes, the buffered trigger fires automatically.
BufferAllBuffer all pending triggers with no cap; each buffered trigger drains sequentially as previous runs complete.
AllowAllStart the new run regardless of the previous run's state
CancelOtherCancel the previous run, then start the new run
TerminateOtherTerminate the previous run, then start the new run

Managing schedules

Pause and resume

ScheduleManager::pause($schedule);

// The schedule will not trigger while paused.

ScheduleManager::resume($schedule);
// next_fire_at is recalculated from now.

Update

ScheduleManager::update(
$schedule,
cronExpression: '30 3 * * *',
timezone: 'America/Chicago',
overlapPolicy: ScheduleOverlapPolicy::AllowAll,
notes: 'Moved to 3:30 AM CT.',
);

Updating the cron expression or timezone recalculates next_fire_at.

Delete

ScheduleManager::delete($schedule);

Deleting is soft — the row remains with status deleted and a deleted_at timestamp. A deleted schedule cannot be paused, resumed, updated, or triggered.

Describe

$description = ScheduleManager::describe($schedule);

$description->scheduleId; // 'daily-invoice-sync'
$description->namespace; // 'default'
$description->status; // ScheduleStatus::Active
$description->spec; // ['cron_expressions' => ['0 2 * * *'], 'timezone' => 'America/New_York']
$description->overlapPolicy; // ScheduleOverlapPolicy::Skip
$description->firesCount; // 47
$description->nextFireAt; // DateTimeInterface|null
$description->lastFiredAt; // DateTimeInterface|null
$description->latestInstanceId; // 'schedule:daily-invoice-sync:...'
$description->jitterSeconds; // 0
$description->note; // 'Runs every night at 2 AM ET.'
$description->toArray(); // full array representation

Find by schedule ID

$schedule = ScheduleManager::findByScheduleId('daily-invoice-sync');

Triggering schedules

Manual trigger

$instanceId = ScheduleManager::trigger($schedule);

This immediately evaluates the overlap policy and, if allowed, starts a new workflow run. Returns the instance ID of the started workflow, or null if the trigger was skipped.

Tick (evaluate all due schedules)

$results = ScheduleManager::tick();

// Returns rows with schedule_id, instance_id, outcome, occurrence_time,
// last_fired_at, and next_fire_at when those fields apply.

tick() finds all active schedules whose next_fire_at is in the past and triggers them in order. The occurrence_time field is the due fire time the scheduler observed. After each trigger, next_fire_at advances from the current clock to the next cron or interval occurrence.

Missed-fire policy

Live schedule evaluation uses fire once, then resume semantics. If the scheduler is down across one or more nominal fire times, the first tick after recovery starts one workflow for the overdue next_fire_at, records that due time as occurrence_time, and then advances next_fire_at from the current clock. Additional nominal occurrences that passed while the scheduler was down are skipped by live evaluation.

Use backfill() when every missed occurrence must run. Backfill enumerates the requested window explicitly and records each occurrence separately.

Artisan command

Run a single tick from the command line:

php artisan workflow:v2:schedule-tick
php artisan workflow:v2:schedule-tick --json

The standalone server exposes the same evaluation pass with its own command:

php artisan schedule:evaluate --limit=100

To evaluate schedules continuously, call this command from Laravel's task scheduler:

// app/Console/Kernel.php
$schedule->command('workflow:v2:schedule-tick')->everyMinute();

Max runs

When maxRuns is set, the schedule tracks remaining_actions. After the last allowed trigger, the schedule is automatically soft-deleted.

$schedule = ScheduleManager::create(
scheduleId: 'one-shot-retry',
workflowClass: RetryWorkflow::class,
cronExpression: '*/5 * * * *',
maxRuns: 3,
);

// After 3 triggers, the schedule status becomes 'deleted'.

Backfill

Backfill triggers workflows for past cron occurrences that were missed (e.g., after a schedule was paused, a deployment outage, or late creation):

$results = ScheduleManager::backfill(
$schedule,
from: new DateTimeImmutable('2026-04-10 00:00:00'),
to: new DateTimeImmutable('2026-04-14 00:00:00'),
);

// Returns: [['schedule_id' => '...', 'instance_id' => '...|null', 'cron_time' => '...'], ...]

Each missed cron occurrence is triggered sequentially. The schedule's overlap policy applies to each occurrence, with one exception: buffer policies (BufferOne, BufferAll) are treated as AllowAll during backfill. Buffering is a real-time flow-control mechanism that has no meaning for catch-up operations — backfill should start every missed occurrence, not queue them into a buffer that will never drain. You can also override the policy explicitly:

$results = ScheduleManager::backfill(
$schedule,
from: new DateTimeImmutable('2026-04-10 00:00:00'),
to: new DateTimeImmutable('2026-04-14 00:00:00'),
overlapPolicyOverride: ScheduleOverlapPolicy::AllowAll,
);

Backfill respects maxRuns — if the schedule's remaining actions are exhausted mid-backfill, the operation stops and the schedule is auto-deleted.

Backfill instance IDs are deterministic: schedule:{scheduleId}:backfill:{timestamp}.

Queue routing

When connection or queue is set on a schedule, triggered workflows dispatch to that connection and queue instead of the workflow class default:

$schedule = ScheduleManager::create(
scheduleId: 'priority-sync',
workflowClass: InvoiceSyncWorkflow::class,
cronExpression: '0 * * * *',
connection: 'redis',
queue: 'high-priority',
);

// Every triggered run dispatches to redis/high-priority,
// regardless of InvoiceSyncWorkflow's default routing.

The routing precedence is: schedule fields → workflow class defaults → global queue config.

Jitter

When multiple schedules share the same cron expression, they all fire at the exact same instant, creating a thundering-herd spike. The jitterSeconds parameter spreads triggers across a random window to smooth the load.

$schedule = ScheduleManager::create(
scheduleId: 'hourly-report',
workflowClass: ReportWorkflow::class,
cronExpression: '0 * * * *',
jitterSeconds: 300, // fire within 0–300 seconds after the top of the hour
);

When jitterSeconds is set, each computed next_fire_at is offset by a random value between 0 and jitterSeconds (inclusive). The jitter is re-rolled every time the next fire time is calculated — after a trigger, after a resume, or after an update.

Jitter applies only to the tick-evaluation fire time stored in the database. Backfill enumeration always uses canonical (unjittered) cron times so that backfilled occurrences land on exact cron boundaries.

Setting jitterSeconds to 0 (the default) disables jitter entirely — fire times are exact cron matches.

History event types

Schedule lifecycle events are recorded on two separate event streams:

  • Workflow-run lineage. When a schedule triggers a workflow, a ScheduleTriggered event is appended to the started run's history (workflow_history_events). This gives the run a verifiable link back to the schedule that started it.
  • Schedule audit stream. Every schedule lifecycle transition is recorded in the per-schedule audit log (workflow_schedule_history_events) under a monotonically increasing sequence field. The audit stream is authoritative for "what happened to this schedule"; the run history is authoritative for "why this run was started".

Both streams use the same HistoryEventType enum and the same HistoryEventPayloadContract payload-key registry, so event names and payload shapes stay in sync across streams.

Run-lineage event (on the started workflow run)

ScheduleTriggered is appended to the triggered workflow run's history with the following payload keys:

  • schedule_id — schedule's user-facing identifier.
  • schedule_ulid — schedule's internal ULID primary key.
  • cron_expression, timezone, overlap_policy — the schedule's primary cron expression, IANA timezone, and active overlap policy at trigger time.
  • trigger_number — which trigger this was (1-indexed).
  • occurrence_time — the schedule fire time the scheduler evaluated. Tick-driven triggers record the due next_fire_at; backfill triggers record the enumerated backfill occurrence. With jitter enabled, the tick-driven value includes the jittered fire time stored on the schedule.

Schedule audit stream (on the schedule itself)

Every schedule lifecycle transition is recorded on the schedule's own audit stream. Sequences start at 1 for ScheduleCreated and increment monotonically per schedule.

EventWhen recordedPayload keys
ScheduleCreatedSchedule was createdspec, action, overlap_policy, next_fire_at, command_context
SchedulePausedSchedule was pausedreason, paused_at, command_context
ScheduleResumedSchedule was resumednext_fire_at, command_context
ScheduleUpdatedSchedule cron, timezone, spec, action, or policy was changedchanged_fields, spec, action, overlap_policy, next_fire_at, command_context
ScheduleTriggeredA workflow run was started from the scheduleworkflow_instance_id, workflow_run_id, outcome, effective_overlap_policy, trigger_number, occurrence_time, command_context
ScheduleTriggerSkippedA trigger was skipped due to overlap policy, non-triggerable status, or exhausted actionsreason, skipped_trigger_count, last_skipped_at, command_context
ScheduleDeletedSchedule was soft-deleted — either by an explicit delete call or by exhausting max_runs (reason: max_runs_exhausted)reason, deleted_at, command_context

command_context carries the principal, request id, source, and reason attributes recorded by ScheduleManager when the caller passes a CommandContext. It is optional — events recorded without a context omit the key rather than writing a blank value.

Payload contract stability

The payload keys in both tables above are declared in Workflow\V2\Support\HistoryEventPayloadContract, asserted on every write, and pinned by test coverage in the workflow package. Adding a new key to any schedule event is a wire-format change and follows the history-event change rules in the version compatibility contract.

Retention

The audit stream is retained for the life of the schedule row. Schedule deletion is a soft delete that writes a final ScheduleDeleted event; the audit rows themselves are not cascaded when a schedule row is removed. Operators that purge historical schedules must choose an explicit retention strategy for their deployment — the package ships no built-in TTL for audit events.

Visibility

The audit stream is exposed through every operator surface:

  • In-process (Eloquent). WorkflowSchedule::historyEvents() (resolved through ConfiguredV2Models::query( 'schedule_history_event_model', ...)) returns the stream for a schedule from within the host application.
  • Waterline HTTP.GET /waterline/api/v2/schedules/{scheduleId}/history returns the stream with limit (1–500, default 100) and after_sequence cursor pagination. The response is scoped to the Waterline namespace so multi-tenant deployments only see their tenant's audit rows.
  • Waterline UI. The History action on each row in the Waterline schedule registry opens a modal that renders the stream for that schedule. Events are shown with their sequence, recorded timestamp, event type, linked workflow instance and run IDs, and formatted payload, with a Load more control that advances the cursor in batches of 100.
  • Standalone server HTTP.GET /api/schedules/{scheduleId}/history on the standalone server returns the same stream with the same pagination contract and the same X-Namespace scoping rules. History remains available after a schedule is soft-deleted, since the audit trail is what operators reach for to reconstruct a removed schedule.
  • CLI. dw schedule:history <schedule-id> prints the stream as a table (Seq, Event, Recorded At, Workflow Refs) with a More events available hint when has_more is true. --limit and --after-sequence forward to the server endpoint, --all pages through every remaining event, and --output=json / --output=jsonl emit structured output (jsonl drops the cursor envelope so each line is a self-contained event).
  • Python SDK. Client.get_schedule_history(schedule_id, *, limit=None, after_sequence=None) returns a single ScheduleHistoryPage, and Client.iter_schedule_history(schedule_id, *, limit=None, after_sequence=None) is an AsyncIterator[ScheduleHistoryEvent] that pages through the full stream with the same keyword arguments. ScheduleHandle exposes matching .history(...) and .iter_history(...) convenience methods. limit is clamped server-side between 1 and 500 (default 100) and after_sequence is a non-negative cursor obtained from the previous page's next_cursor.

All of these surfaces read from the same workflow_schedule_history_events table; the payload-key contract in Payload contract stability applies regardless of which surface the operator uses.

Migration behavior for pre-audit schedules

The schedule audit stream was introduced together with the workflow_schedule_history_events table. Schedules that were created before that migration ran have no retroactive ScheduleCreated event — ScheduleManager records audit events only as lifecycle transitions happen, and the migration does not synthesize a backfilled event for existing schedules.

What this means for operators:

  • A pre-existing schedule's stream is empty until its next lifecycle transition. Pausing, resuming, updating, triggering, skipping a trigger, or deleting the schedule appends events as normal from that point on.
  • Sequence numbers still start at 1 and increment monotonically per schedule. A pre-existing schedule whose first recorded event is a SchedulePaused will have sequence = 1 on that row; the absence of a preceding ScheduleCreated is expected.
  • ScheduleTriggered and ScheduleTriggerSkipped events are written every time a tick evaluates the schedule, so any schedule that fires on a cron cadence after the migration will accumulate audit rows without operator intervention.
  • No operator action is required to opt a schedule into the audit stream. The stream is always on; it simply has no rows until the first post-migration event is written.

Skip tracking

When a trigger is skipped (due to overlap policy, non-triggerable status, or exhausted actions), the schedule tracks the skip:

  • last_skip_reason — why the most recent trigger was skipped (e.g., overlap_policy_skip, status_not_triggerable, remaining_actions_exhausted)
  • last_skipped_at — when the skip occurred
  • skipped_trigger_count — cumulative number of skipped triggers

These fields are included in ScheduleManager::describe() and the Waterline schedule detail API.

Namespace scoping

Schedules belong to a namespace. When namespace is passed to create() or createFromSpec(), the schedule is scoped to that namespace. When omitted, the schedule inherits the configured workflows.v2.namespace (defaulting to 'default').

Schedule IDs are unique within a namespace — the same scheduleId can exist in different namespaces without conflict.

$schedule = ScheduleManager::create(
scheduleId: 'daily-sync',
workflowClass: SyncWorkflow::class,
cronExpression: '0 2 * * *',
namespace: 'billing',
);

// Find by schedule ID within a namespace:
$found = ScheduleManager::findByScheduleId('daily-sync', namespace: 'billing');

When Waterline is configured with a namespace (waterline.namespace), the schedule list and detail endpoints automatically scope to that namespace. This ensures multi-tenant deployments show only the schedules belonging to the operator's namespace.

Database

The schedule table (workflow_schedules) is created by migration 2026_04_14_000157. The model class is configurable via workflows.v2.schedule_model.

If your deployment runs package migrations alongside application migrations, migration 157 detects a pre-existing workflow_schedules table and handles it gracefully: if the table already matches the package schema it is left as-is; if it was created by an earlier shim migration with a different schema, it is replaced.