Scheduling (Cron)¶
The v3 scheduler lets you register recurring jobs in code and have them evaluated once a minute by a
single system cron entry. Scheduled jobs are registered in Config\Jobs::init(Scheduler $scheduler)
and evaluated by the jobs:cronjob:run command. For each registered definition the runner checks
the cron expression against the current minute and, when due, either enqueues the job onto its
backend (when it declares a queue()) or executes it inline in the same process (when it has no
queue).
This is the v3 replacement for the legacy Scheduler. Definitions are immutable
JobDefinition value objects produced by the same fluent JobBuilder used for ad-hoc dispatch, so
everything you know from Queues & Backends applies here too.
Registering jobs¶
Override init() in your application’s Config\Jobs (which extends
Daycry\Jobs\Config\Jobs). It receives a Daycry\Jobs\Cron\Scheduler. Call
$scheduler->define($handler, $payload) once per job; it returns a fluent JobBuilder so you can
chain frequency, queue and identity helpers.
<?php
namespace Config;
use Daycry\Jobs\Config\Jobs as BaseJobs;
use Daycry\Jobs\Cron\Scheduler;
class Jobs extends BaseJobs
{
public function init(Scheduler $scheduler): void
{
// Enqueued onto the 'reports' queue — a worker (jobs:queue:work) runs it asynchronously.
$scheduler->define('command', 'app:report')
->named('daily-report')
->dailyAt('02:00')
->queue('reports')
->maxRetries(3);
// No queue() -> executed INLINE by the cron runner itself, one attempt.
$scheduler->define('shell', ['ls', '-la'])
->named('list-files')
->everyMinute()
->singleInstance();
// Closures can only run inline (they cannot be serialised to a remote backend).
$scheduler->define('closure', static fn (): string => 'done')
->named('housekeeping')
->hourly();
// Restricted to a specific CI4 environment and registered but disabled.
$scheduler->define('url', ['method' => 'GET', 'url' => 'https://example.com/ping'])
->named('ping')
->everyMinute()
->environments('production')
->disable();
}
}
Note:
closurejobs run inline only. A closure cannot be serialized onto a remote backend, so do not give aclosuredefinition aqueue(). Usecommand,shell,urloreventfor queued work. See the handler reference in Configuration.
Running the scheduler¶
jobs:cronjob:run must run every minute. Add one entry to your operating system’s crontab:
* * * * * cd /path/to/project && php spark jobs:cronjob:run >> /dev/null 2>&1
The command itself is the only thing the OS cron drives; it evaluates all registered definitions and acts only on those due for the current minute.
# Run the due jobs now
php spark jobs:cronjob:run
# Evaluate the schedule against a frozen time (deterministic dry-run / testing)
php spark jobs:cronjob:run -testTime "2026-06-03 02:00:00"
The runner evaluates cron expressions using Config\App::$appTimezone, so schedules are interpreted
in your application timezone rather than UTC or the server’s locale.
Note: The runner evaluates the schedule on every invocation, out of the box — there is no global on/off flag to set first. Control which jobs run on a per-job basis with
enabled()/disable()andenvironments()on the definition (see Enabled / disabled and Environments below).
Frequency helpers¶
JobBuilder keeps the five standard cron fields (minute, hour, day-of-month, month, day-of-week) and
recomposes the expression on every call, so helpers compose predictably.
Helper |
Resulting cron |
Meaning |
|---|---|---|
|
|
Every minute |
|
|
Every 5 minutes |
|
|
Every 5 minutes (explicit alias) |
|
|
Top of every hour |
|
|
15 minutes past every hour |
|
|
Every day at midnight |
|
|
Every day at 02:30 |
|
|
Sundays at midnight |
|
|
1st of the month at midnight |
|
|
1st of Jan/Apr/Jul/Oct at midnight |
|
|
January 1st at midnight |
Note:
dailyAt('HH:MM')normalises the time without leading zeros, so'02:30'yields the hour field2(matching standard crontab output), not02.
For full control, set a raw expression with cron():
$scheduler->define('command', 'app:purge')
->named('purge')
->cron('30 3 * * 1-5'); // 03:30, Monday to Friday
Warning:
cron()validates its argument and throws aRuntimeExceptionat registration time for an invalid expression (and the expression must have exactly five fields). Catch configuration errors in CI rather than in production cron.
Queued vs inline — the rule¶
The single rule that decides how a due job runs:
Definition declares |
Behaviour in |
|---|---|
|
Enqueued onto the configured default backend ( |
no |
Executed inline by the cron runner via |
// Queued: cron enqueues it, jobs:queue:work runs it (subject to maxRetries, backoff, the reaper).
$scheduler->define('command', 'app:report')->named('report')->dailyAt('02:00')->queue('reports');
// Inline: cron runs it directly, blocking the cron process for the duration of one attempt.
$scheduler->define('command', 'app:vacuum')->named('vacuum')->daily();
Note: Queued cron jobs always enqueue onto the default backend (
$worker); the scheduler does not pick a per-job backend. Inline jobs do not get retries or backoff — a failed inline job is simply a failedExecutionResultfor that tick.
Enabled / disabled¶
Every definition is enabled by default. Use disable() (alias of enabled(false)) to keep a job
registered but skipped, and enabled(true) to turn it back on:
$scheduler->define('command', 'app:experimental')
->named('experimental')
->everyMinute()
->disable();
The cron runner short-circuits on disabled definitions before evaluating their schedule.
Environments¶
Restrict a job to specific CodeIgniter environments. An empty list (the default) imposes no
restriction. environments() accepts a variadic list or a single array:
$scheduler->define('command', 'app:report')->named('report')->environments('production');
$scheduler->define('command', 'app:debug')->named('debug')->environments(['development', 'testing']);
The runner compares the active ENVIRONMENT constant against this list and skips the job when it
does not match.
Dependencies and execution order¶
dependsOn() declares the names of jobs that must come first. Before evaluating any schedule,
the scheduler sorts all definitions topologically (Scheduler::getExecutionOrder()), so a dependency
is always evaluated before its dependents within a single cron run. Names default from the handler +
a hash of the payload when named() is not set; declare named() explicitly to reference a job in
dependsOn().
$scheduler->define('command', 'app:extract')
->named('extract')->dailyAt('01:00')->queue('etl');
$scheduler->define('command', 'app:transform')
->named('transform')->dailyAt('01:00')->queue('etl')->dependsOn('extract');
$scheduler->define('command', 'app:load')
->named('load')->dailyAt('01:00')->queue('etl')->dependsOn('transform');
Notes and caveats:
The order only affects the sequence in which due definitions are evaluated within one
jobs:cronjob:runinvocation. It is not a workflow engine: a job that declares aqueue()is still merely enqueued (a worker runs it later), so dependents do not wait for a queued dependency’s actual completion.Referencing an unknown dependency name, or introducing a cycle, throws a
RuntimeExceptionwhen the order is computed.
Single instance¶
singleInstance() marks a definition as non-overlapping. The package backs this flag with
Daycry\Jobs\Execution\SingleInstanceLock (cache-backed, ownership-token based), wired into
JobRuntime: if the same named job is already running, the new attempt fails fast with
"single-instance job '<name>' is already running". See
Single instance for the lock semantics and best-effort caveats.
$scheduler->define('command', 'app:long-import')
->named('long-import')
->everyMinute()
->singleInstance(); // a second overlapping run is rejected instead of running concurrently
Missed runs¶
Scheduling is best-effort per tick. jobs:cronjob:run evaluates whether each expression is due
for the current minute and acts on it; it does not keep a history of past runs or back-fill
ticks that were missed.
Warning: If the OS cron does not fire (the machine was down, cron was paused, or a previous inline run overran the minute window), the missed occurrences are not replayed. For jobs where a missed window is unacceptable, prefer a queued definition (so the work is durable once enqueued) and keep inline cron jobs short. Do not run two
jobs:cronjob:runprocesses for the same schedule concurrently, or due jobs may be dispatched twice within the same minute.
See also¶
Queues & Backends — the worker, lease semantics and backend configuration for queued cron jobs.
CLI Commands — the full
jobs:cronjob:runreference.Configuration — registering jobs via
init().Operations — running the worker that consumes queued cron jobs in production.