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
| Parameter | Type | Default | Description |
|---|---|---|---|
scheduleId | string | required | Unique identifier for the schedule |
workflowClass | string | required | The workflow class to start |
cronExpression | string | required | Standard cron expression (5 fields) |
arguments | array | [] | Arguments passed to the workflow's handle() method |
timezone | string | 'UTC' | Timezone for evaluating the cron expression |
overlapPolicy | ScheduleOverlapPolicy | Skip | What to do when the previous run is still active |
labels | array | [] | Visibility labels applied to each triggered run |
memo | array | [] | Memo fields applied to each triggered run |
searchAttributes | array | [] | Search attributes applied to each triggered run |
jitterSeconds | int | 0 | Maximum random delay in seconds added to each fire time (thundering-herd mitigation) |
maxRuns | int\|null | null | Maximum number of runs before auto-deleting the schedule |
connection | string\|null | null | Queue connection for triggered runs (overrides the workflow class default) |
queue | string\|null | null | Queue name for triggered runs (overrides the workflow class default) |
notes | string\|null | null | Free-form operator notes |
namespace | string\|null | null | Namespace 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
| Field | Type | Description |
|---|---|---|
every | string | ISO 8601 duration (e.g., PT30M for 30 minutes, PT1H for 1 hour, P1D for 1 day) |
offset | string\|null | Phase 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:
| Policy | Behavior |
|---|---|
Skip | Do not start a new run (default) |
BufferOne | Buffer 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. |
BufferAll | Buffer all pending triggers with no cap; each buffered trigger drains sequentially as previous runs complete. |
AllowAll | Start the new run regardless of the previous run's state |
CancelOther | Cancel the previous run, then start the new run |
TerminateOther | Terminate 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
ScheduleTriggeredevent 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 increasingsequencefield. 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 duenext_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.
| Event | When recorded | Payload keys |
|---|---|---|
ScheduleCreated | Schedule was created | spec, action, overlap_policy, next_fire_at, command_context |
SchedulePaused | Schedule was paused | reason, paused_at, command_context |
ScheduleResumed | Schedule was resumed | next_fire_at, command_context |
ScheduleUpdated | Schedule cron, timezone, spec, action, or policy was changed | changed_fields, spec, action, overlap_policy, next_fire_at, command_context |
ScheduleTriggered | A workflow run was started from the schedule | workflow_instance_id, workflow_run_id, outcome, effective_overlap_policy, trigger_number, occurrence_time, command_context |
ScheduleTriggerSkipped | A trigger was skipped due to overlap policy, non-triggerable status, or exhausted actions | reason, skipped_trigger_count, last_skipped_at, command_context |
ScheduleDeleted | Schedule 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 throughConfiguredV2Models::query( 'schedule_history_event_model', ...)) returns the stream for a schedule from within the host application. - Waterline HTTP.
GET /waterline/api/v2/schedules/{scheduleId}/historyreturns the stream withlimit(1–500, default 100) andafter_sequencecursor 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}/historyon the standalone server returns the same stream with the same pagination contract and the sameX-Namespacescoping 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 whenhas_moreis true.--limitand--after-sequenceforward to the server endpoint,--allpages through every remaining event, and--output=json/--output=jsonlemit structured output (jsonldrops 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 singleScheduleHistoryPage, andClient.iter_schedule_history(schedule_id, *, limit=None, after_sequence=None)is anAsyncIterator[ScheduleHistoryEvent]that pages through the full stream with the same keyword arguments.ScheduleHandleexposes matching.history(...)and.iter_history(...)convenience methods.limitis clamped server-side between 1 and 500 (default 100) andafter_sequenceis a non-negative cursor obtained from the previous page'snext_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
1and increment monotonically per schedule. A pre-existing schedule whose first recorded event is aSchedulePausedwill havesequence = 1on that row; the absence of a precedingScheduleCreatedis expected. ScheduleTriggeredandScheduleTriggerSkippedevents 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 occurredskipped_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.