Scheduled jobs¶
naf/schedule checks cron expressions and enqueues due jobs. A queue worker executes them.
The package installs Queue and CLI. Run a ticker and worker with the same application
configuration and queue storage.
Required packages
In an existing NAF application:
composer require naf/schedule
Also installs these transitive dependencies: naf/cli, naf/queue.
A scheduled job¶
Start from Your first application, install Schedule, and create the following job. It writes to worker output every minute so setup can be verified without external effects.
<?php
declare(strict_types=1);
namespace App\Jobs;
use Naf\CLI\Core\Output;
use Naf\Queue\Core\QueueJobInterface;
use Naf\Schedule\Core\ScheduledJobInterface;
final class RecordHeartbeat implements QueueJobInterface, ScheduledJobInterface
{
public function __construct(private string $message = 'Scheduled heartbeat executed.') {}
public function getCronExpression(): string
{
return '* * * * *';
}
public function execute(Output $output): void
{
$output->writeLine($this->message);
}
}
The cron expression uses the PHP process's timezone. For example, 0 3 * * * runs at 03:00
in that timezone. Configure it consistently across ticker processes.
Registering it¶
Create this application registration file. It gives this application its own persistent state file rather than using the default shared temporary filename:
<?php
declare(strict_types=1);
use App\Jobs\RecordHeartbeat;
use Naf\Queue\Core\Queue;
use Naf\Schedule\Core\JobRepository;
use Naf\Schedule\Core\Scheduler;
use Naf\Schedule\Support\CronParser;
use function Naf\app;
use function Naf\Schedule\scheduler;
$container = app()->container();
$container->set(Scheduler::class, static fn() => new Scheduler(
$container->get(Queue::class),
$container->get(JobRepository::class),
$container->get(CronParser::class),
BASE_PATH . '/storage/schedule-state.json',
));
scheduler()->addScheduledJob(RecordHeartbeat::class);
Create storage/ and add this include to root bootstrap.php, after Composer autoloading
and before app()->run():
require_once BASE_PATH . '/app/schedule.php';
Each scheduled class can be registered once; a second registration replaces the first.
vendor/bin/naf schedule:list shows registered jobs and their next occurrence.
--from="2026-10-12 08:00" calculates occurrences from another start time, and --no-sort
keeps registration order instead of sorting by the next occurrence.
Passing job data¶
Replace the final registration in app/schedule.php with this fragment to supply data:
scheduler()->addScheduledJob(RecordHeartbeat::class, [
'message' => 'Scheduled catalog check executed.',
]);
The ticker constructs the job with that payload to read its cron expression, then queues
the payload for the worker to construct it again. Here message matches the constructor
parameter $message; explicit names take precedence over its default. Keep job data
JSON-serializable and validate values before using them. Reserve keys beginning with _
for queue metadata.
The worker now prints Scheduled catalog check executed.. A second registration of the
same class replaces its payload; it does not create another schedule. Payload values are
not part of the class/expression key used for minute tracking and coalescing.
If a job instead takes the whole array, use a required array $payload parameter with
nonempty job data, as in Queues. An optional
array $payload = [] uses its default before the container's unmatched-array fallback,
so unrelated keys such as message would be ignored. See constructor parameter resolution.
Ticker and worker¶
Scheduling needs two processes. The ticker only decides that a job is due and queues it; a queue worker runs it:
flowchart TD
S[("Scheduler state file")] <--> T["schedule:ticker"]
T -- "due this minute" --> Q[("Queue storage")]
Q --> W["queue:consume worker"]
W -- "execute()" --> J["Your scheduled job"]
Verify the example from the project root:
mkdir -p storage
composer dump-autoload
vendor/bin/naf schedule:ticker --once
vendor/bin/naf queue:consume --once
Expect the ticker to report a queued RecordHeartbeat, then the worker to print
Scheduled heartbeat executed. Repeating the ticker within the same recorded minute does
not enqueue that schedule again.
For continuous operation, run both commands under a supervisor:
vendor/bin/naf schedule:ticker
vendor/bin/naf queue:consume
A running ticker without a worker leaves jobs waiting in the queue. Stop and remove the demo registration when replacing it with application jobs.
Running the ticker¶
| Option | Effect |
|---|---|
--once |
One scheduling pass, then exit |
--max-jobs=N |
Exit after queueing N jobs |
--max-runtime=N |
Exit after N seconds |
--workers=N |
Start N queue-worker child processes |
Child workers write under logs/queue/. The ticker owns them and closes them on exit.
Separate supervised workers allow independent scaling and restarts.
[program:naf-schedule]
directory=/var/www/my-app
command=php /var/www/my-app/vendor/bin/naf schedule:ticker --max-runtime=3600
autostart=true
autorestart=true
Replace the path with the deployed application. Configure a separately supervised worker
unless using --workers. See Deployment.
Missed occurrences¶
The ticker evaluates the current minute; it does not catch up missed occurrences. A daily 03:00 job on a machine unavailable until 03:05 waits until the next scheduled day. For tasks that must eventually run, persist completion state and check overdue work in application logic.
Coalescing queued runs¶
By default, the scheduler adds a deterministic _job_id for each class/expression pair.
The file queue uses it as a filename, so later occurrences replace an unconsumed run of that
same schedule. This suits work such as rebuilding the latest cache state.
| Work | Coalescing choice |
|---|---|
| Refresh a snapshot, rebuild a cache or synchronize the latest state | Keep true when one pending run can replace an older pending run |
| Process each observed occurrence, such as recording a periodic audit sample | Use false and make individual runs identifiable and safe to retry |
Disabling coalescing preserves runs that the ticker actually queues. It does not recover occurrences missed while the ticker was stopped, or make delivery exactly once.
Add this fragment to app/config.php when every occurrence must be queued separately:
'schedule' => ['queue' => ['coalesce' => false]],
Coalescing depends on the driver honoring _job_id; an append-only driver may not implement
it. It is not a guarantee that side effects execute exactly once.
Duplicate prevention and state¶
The scheduler records the last queued minute under a file lock and replaces its state file
atomically. Tickers coordinate only when they use the same state file and lock on a filesystem
that supports those operations. The default is sys_get_temp_dir() . '/naf-schedule-state.json';
use an application-specific path as in the example and preserve it across restarts.
A failed enqueue is not recorded as complete. A crash after enqueue but before recording can still duplicate work. Design non-repeatable side effects around durable application identifiers and idempotency, as with queue leases.
Set schedule:heartbeat_file to an application-specific writable path to record ticker
polling. A heartbeat does not prove that jobs completed successfully.
Configuration¶
| Key | Type | Default | Effect |
|---|---|---|---|
schedule:queue:coalesce |
bool | true |
Give each class/expression pair one job id, so an unconsumed run is replaced instead of duplicated |
schedule:heartbeat_file |
string | — | File the ticker updates with a timestamp on every pass |
The state file location is a constructor argument of Scheduler, not a configuration key;
register it as in Registering it.
If the result is different¶
| What you see | What to check |
|---|---|
| The ticker queues the job but nothing runs | A queue worker is running for the default channel |
| The ticker queues nothing | The registration file is included from bootstrap.php; the cron expression matches the current minute in the PHP timezone; schedule:list shows the job |
A second --once pass in the same minute queues nothing |
Expected: the state file records the minute |
| A job runs twice | Two tickers use different state files; point them at the same file |