|
| 1 | +--- |
| 2 | +sidebar_position: 18 |
| 3 | +--- |
| 4 | + |
| 5 | +# Schedules |
| 6 | + |
| 7 | +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. |
| 8 | + |
| 9 | +## Creating a schedule |
| 10 | + |
| 11 | +Use `ScheduleManager::create()` to define a named schedule: |
| 12 | + |
| 13 | +```php |
| 14 | +use Workflow\V2\Enums\ScheduleOverlapPolicy; |
| 15 | +use Workflow\V2\Support\ScheduleManager; |
| 16 | + |
| 17 | +$schedule = ScheduleManager::create( |
| 18 | + scheduleId: 'daily-invoice-sync', |
| 19 | + workflowClass: InvoiceSyncWorkflow::class, |
| 20 | + cronExpression: '0 2 * * *', |
| 21 | + arguments: ['nightly'], |
| 22 | + timezone: 'America/New_York', |
| 23 | + overlapPolicy: ScheduleOverlapPolicy::Skip, |
| 24 | + labels: ['team' => 'billing'], |
| 25 | + memo: ['origin' => 'scheduled'], |
| 26 | + searchAttributes: ['tenant_id' => '42'], |
| 27 | + notes: 'Runs every night at 2 AM ET.', |
| 28 | +); |
| 29 | +``` |
| 30 | + |
| 31 | +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. |
| 32 | + |
| 33 | +### Parameters |
| 34 | + |
| 35 | +| Parameter | Type | Default | Description | |
| 36 | +|---|---|---|---| |
| 37 | +| `scheduleId` | `string` | required | Unique identifier for the schedule | |
| 38 | +| `workflowClass` | `string` | required | The workflow class to start | |
| 39 | +| `cronExpression` | `string` | required | Standard cron expression (5 fields) | |
| 40 | +| `arguments` | `array` | `[]` | Arguments passed to the workflow's `handle()` method | |
| 41 | +| `timezone` | `string` | `'UTC'` | Timezone for evaluating the cron expression | |
| 42 | +| `overlapPolicy` | `ScheduleOverlapPolicy` | `Skip` | What to do when the previous run is still active | |
| 43 | +| `labels` | `array` | `[]` | Visibility labels applied to each triggered run | |
| 44 | +| `memo` | `array` | `[]` | Memo fields applied to each triggered run | |
| 45 | +| `searchAttributes` | `array` | `[]` | Search attributes applied to each triggered run | |
| 46 | +| `jitterSeconds` | `int` | `0` | Reserved for future random jitter support | |
| 47 | +| `maxRuns` | `int\|null` | `null` | Maximum number of runs before auto-deleting the schedule | |
| 48 | +| `connection` | `string\|null` | `null` | Queue connection for triggered runs | |
| 49 | +| `queue` | `string\|null` | `null` | Queue name for triggered runs | |
| 50 | +| `notes` | `string\|null` | `null` | Free-form operator notes | |
| 51 | + |
| 52 | +## Overlap policies |
| 53 | + |
| 54 | +When a schedule fires and the previous run is still active, the overlap policy controls behavior: |
| 55 | + |
| 56 | +| Policy | Behavior | |
| 57 | +|---|---| |
| 58 | +| `Skip` | Do not start a new run (default) | |
| 59 | +| `BufferOne` | Buffer one pending trigger; skip further triggers until the active run completes | |
| 60 | +| `AllowAll` | Start the new run regardless of the previous run's state | |
| 61 | +| `CancelOther` | Cancel the previous run, then start the new run | |
| 62 | +| `TerminateOther` | Terminate the previous run, then start the new run | |
| 63 | + |
| 64 | +## Managing schedules |
| 65 | + |
| 66 | +### Pause and resume |
| 67 | + |
| 68 | +```php |
| 69 | +ScheduleManager::pause($schedule); |
| 70 | + |
| 71 | +// The schedule will not trigger while paused. |
| 72 | + |
| 73 | +ScheduleManager::resume($schedule); |
| 74 | +// next_run_at is recalculated from now. |
| 75 | +``` |
| 76 | + |
| 77 | +### Update |
| 78 | + |
| 79 | +```php |
| 80 | +ScheduleManager::update( |
| 81 | + $schedule, |
| 82 | + cronExpression: '30 3 * * *', |
| 83 | + timezone: 'America/Chicago', |
| 84 | + overlapPolicy: ScheduleOverlapPolicy::AllowAll, |
| 85 | + notes: 'Moved to 3:30 AM CT.', |
| 86 | +); |
| 87 | +``` |
| 88 | + |
| 89 | +Updating the cron expression or timezone recalculates `next_run_at`. |
| 90 | + |
| 91 | +### Delete |
| 92 | + |
| 93 | +```php |
| 94 | +ScheduleManager::delete($schedule); |
| 95 | +``` |
| 96 | + |
| 97 | +Deleting is soft — the row remains with status `deleted` and a `deleted_at` timestamp. A deleted schedule cannot be paused, resumed, updated, or triggered. |
| 98 | + |
| 99 | +### Describe |
| 100 | + |
| 101 | +```php |
| 102 | +$description = ScheduleManager::describe($schedule); |
| 103 | + |
| 104 | +$description->scheduleId; // 'daily-invoice-sync' |
| 105 | +$description->status; // ScheduleStatus::Active |
| 106 | +$description->cronExpression; // '0 2 * * *' |
| 107 | +$description->totalRuns; // 47 |
| 108 | +$description->nextRunAt; // DateTimeInterface |
| 109 | +$description->toArray(); // full array representation |
| 110 | +``` |
| 111 | + |
| 112 | +### Find by schedule ID |
| 113 | + |
| 114 | +```php |
| 115 | +$schedule = ScheduleManager::findByScheduleId('daily-invoice-sync'); |
| 116 | +``` |
| 117 | + |
| 118 | +## Triggering schedules |
| 119 | + |
| 120 | +### Manual trigger |
| 121 | + |
| 122 | +```php |
| 123 | +$instanceId = ScheduleManager::trigger($schedule); |
| 124 | +``` |
| 125 | + |
| 126 | +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. |
| 127 | + |
| 128 | +### Tick (evaluate all due schedules) |
| 129 | + |
| 130 | +```php |
| 131 | +$results = ScheduleManager::tick(); |
| 132 | + |
| 133 | +// Returns: [['schedule_id' => '...', 'instance_id' => '...|null'], ...] |
| 134 | +``` |
| 135 | + |
| 136 | +`tick()` finds all active schedules whose `next_run_at` is in the past and triggers them in order. After each trigger, `next_run_at` advances to the next cron occurrence. |
| 137 | + |
| 138 | +### Artisan command |
| 139 | + |
| 140 | +Run a single tick from the command line: |
| 141 | + |
| 142 | +```bash |
| 143 | +php artisan workflow:v2:schedule-tick |
| 144 | +php artisan workflow:v2:schedule-tick --json |
| 145 | +``` |
| 146 | + |
| 147 | +To evaluate schedules continuously, call this command from Laravel's task scheduler: |
| 148 | + |
| 149 | +```php |
| 150 | +// app/Console/Kernel.php |
| 151 | +$schedule->command('workflow:v2:schedule-tick')->everyMinute(); |
| 152 | +``` |
| 153 | + |
| 154 | +## Max runs |
| 155 | + |
| 156 | +When `maxRuns` is set, the schedule tracks `remaining_actions`. After the last allowed trigger, the schedule is automatically soft-deleted. |
| 157 | + |
| 158 | +```php |
| 159 | +$schedule = ScheduleManager::create( |
| 160 | + scheduleId: 'one-shot-retry', |
| 161 | + workflowClass: RetryWorkflow::class, |
| 162 | + cronExpression: '*/5 * * * *', |
| 163 | + maxRuns: 3, |
| 164 | +); |
| 165 | + |
| 166 | +// After 3 triggers, the schedule status becomes 'deleted'. |
| 167 | +``` |
| 168 | + |
| 169 | +## History event types |
| 170 | + |
| 171 | +Schedule lifecycle operations produce typed history events for auditability: |
| 172 | + |
| 173 | +- `ScheduleCreated` — schedule was created |
| 174 | +- `SchedulePaused` — schedule was paused |
| 175 | +- `ScheduleResumed` — schedule was resumed |
| 176 | +- `ScheduleUpdated` — schedule cron, timezone, or policy was changed |
| 177 | +- `ScheduleTriggered` — a workflow run was started from the schedule |
| 178 | +- `ScheduleDeleted` — schedule was soft-deleted |
| 179 | +- `ScheduleTriggerSkipped` — a trigger was skipped due to overlap policy or exhausted actions |
| 180 | + |
| 181 | +## Database |
| 182 | + |
| 183 | +The schedule table (`workflow_schedules`) is created by migration `2026_04_14_000157`. The model class is configurable via `workflows.v2.schedule_model`. |
0 commit comments