Jobs: Defining Work¶
This page describes the v3 job definition model — the way you describe a unit of work before it
runs. In v3 a job is split into a small set of focused objects: you start from the
Daycry\Jobs\Jobs facade, accumulate configuration on a fluent JobBuilder, and materialise an
immutable JobDefinition. When the definition is dispatched it is serialised into a canonical
wire envelope that travels through the queue, independently of which backend you use.
Nothing on this page carries scheduling/queue state into your handlers — that is the handler contract’s job. Here we focus only on describing and dispatching work.
The facade: Jobs¶
Daycry\Jobs\Jobs is the single static entry point. It has exactly two methods:
Method |
Signature |
Returns |
Purpose |
|---|---|---|---|
|
|
|
Open a fluent builder for the given handler key and payload. |
|
|
|
Resolve a configured backend directly (null = the default |
use Daycry\Jobs\Jobs;
// Describe + enqueue a job in one fluent chain.
$id = Jobs::define('command', 'app:report')
->named('daily-report')
->queue('reports')
->maxRetries(3)
->dispatch();
// Resolve a backend directly (e.g. to enqueue a pre-built definition, or to fetch/ack manually).
$backend = Jobs::backend('redis');
Note: The first argument to
define()is a handler key, not a class name. Keys are mapped to handler classes inConfig\Jobs::$handlers. The five built-in keys arecommand,shell,closure,eventandurl. See Handlers for each handler’s payload shape, and Configuration for registering your own keys.
JobBuilder: the fluent accumulator¶
Daycry\Jobs\Definition\JobBuilder is a mutable, throwaway accumulator. Every setter returns
$this, so calls chain. When you are done you call a terminator: toDefinition() produces an
immutable JobDefinition, and dispatch() enqueues it. The builder never runs anything itself.
Builder defaults¶
A fresh builder starts with these defaults (constructor __construct(string $handler, mixed $payload = null)):
Property |
Default |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Identity & routing methods¶
Method |
Description |
|---|---|
|
Set a friendly name used in logs, metrics, the single-instance lock key, and dependency ordering. Defaults to |
|
Target queue. |
|
Higher = sooner. How strictly this is honoured is backend-dependent. Default |
Jobs::define('command', 'app:cleanup')
->named('nightly-cleanup')
->queue('maintenance')
->priority(9)
->dispatch();
Reliability methods¶
Method |
Description |
|---|---|
|
Number of retries after the first attempt. Total runs = |
|
Per-attempt timeout in seconds. |
|
Prevent overlapping runs of the same named job via a cache-backed ownership lock. |
|
Opt-in deduplication key. |
Jobs::define('command', 'app:reconcile')
->named('reconcile')
->maxRetries(5) // up to 6 total runs
->timeout(120) // interrupt after 120s per attempt
->singleInstance() // never run two at once
->idempotencyKey('reconcile-2026-06-03')
->dispatch();
Note: Retries are applied by the worker, not by the runtime. On a failed attempt with retries remaining, the worker
nacks the lease with a backoff delay so the backend redelivers it. See Retries and Advanced topics.
Gating methods¶
Method |
Description |
|---|---|
|
Mark the definition active/inactive. Disabled scheduled jobs are skipped by the cron runner. |
|
Shorthand for |
|
Restrict execution to the listed CodeIgniter environments. Accepts a variadic list or a single array. Empty = no restriction. |
|
Declare job names that must run first within the same scheduler run. Accepts a variadic list or a single array. |
// Only run in production and staging; skip everywhere else.
Jobs::define('command', 'app:send-newsletter')
->named('newsletter')
->environments('production', 'staging')
->dailyAt('08:00')
->queue('mail');
// Dependency ordering (resolved by the scheduler's topological sort).
$scheduler->define('command', 'app:extract')->named('extract')->dailyAt('01:00');
$scheduler->define('command', 'app:transform')->named('transform')->dependsOn('extract');
$scheduler->define('command', 'app:load')->named('load')->dependsOn('transform');
Warning:
dependsOn()only orders jobs within a single scheduler run (jobs:cronjob:run). It does not create cross-process or cross-queue dependencies, and it does not wait for an enqueued job to finish on a worker. See Dependencies.
Scheduling methods¶
scheduledAt() and the cron frequency helpers describe when a job should run. They are
complementary: scheduledAt() is a one-shot earliest-run time for queued work, while the cron
helpers describe a recurring schedule consumed by the cron runner.
Method |
Description |
|---|---|
|
Earliest run time (one-shot delay). Travels on the wire as the |
|
Set the schedule from a raw 5-field crontab string. Throws |
Frequency helpers (each recomposes the underlying 5-field cron expression):
Method |
Resulting cron |
Meaning |
|---|---|---|
|
|
Every minute, or every n minutes. |
|
|
Every n minutes (explicit interval). |
|
|
Top of every hour. |
|
|
At minute m past every hour. |
|
|
Every day at midnight. |
|
|
Every day at |
|
|
Sundays at midnight. |
|
|
First of the month at midnight. |
|
|
First day of each quarter at midnight. |
|
|
First of January at midnight. |
// One-shot delay: do not run before this instant.
Jobs::define('command', 'app:remind')
->scheduledAt(new DateTimeImmutable('2026-06-10 09:00:00'))
->queue('reminders')
->dispatch();
// Recurring schedule (consumed by jobs:cronjob:run; see Scheduling).
$scheduler->define('command', 'app:report')->named('report')->dailyAt('02:30')->queue('reports');
// Raw cron expression.
$scheduler->define('command', 'app:every-15')->named('every-15')->cron('*/15 * * * *');
Note:
dailyAt('02:30')produces the cron fields30 2 * * *— hours and minutes are normalised without leading zeros, matching standard crontab output. See Scheduling for how the cron runner evaluates these expressions.
Terminators¶
Method |
Returns |
Description |
|---|---|---|
|
|
Materialise the accumulated config into an immutable value object without enqueuing. |
|
|
Build the definition and enqueue it onto the named backend (or the configured default), returning the backend-assigned id. |
// Build a definition without dispatching (useful for tests or manual enqueue).
$definition = Jobs::define('command', 'app:report')->maxRetries(2)->toDefinition();
// Dispatch to an explicit backend.
$id = Jobs::define('command', 'app:report')->queue('reports')->dispatch('database');
JobDefinition: the immutable value object¶
Daycry\Jobs\Definition\JobDefinition is a final readonly value object: every withXxx() helper
returns a new instance, so a definition can be safely shared across enqueue sites without any
spooky action at a distance. The builder produces one via toDefinition(); you can also construct
one directly.
Fields¶
Field |
Type |
Default |
Meaning |
|---|---|---|---|
|
|
— |
Handler key (must exist in |
|
|
— |
Arbitrary payload passed to the handler. |
|
|
|
Friendly name; defaults to |
|
|
|
Explicit queue; |
|
|
|
Higher = sooner (backend-dependent). |
|
|
|
Retries after the first attempt; total runs = |
|
|
|
Per-attempt soft timeout (seconds); |
|
|
|
Earliest run time (UTC); |
|
|
|
Lock to prevent concurrent runs of the same name. |
|
|
|
Restrict to these CI4 environments; empty = no restriction. |
|
|
|
Job names that must succeed first within a scheduler run. |
|
|
|
Cron schedule used by the scheduler. |
|
|
|
Free-form metadata propagated to the envelope. |
|
|
|
Whether the definition is active. |
|
|
|
Optional dedup key. |
Immutable mutation (withXxx)¶
Each field has a matching withXxx() copy-on-write helper:
withName, withQueue, withPriority, withMaxRetries, withTimeout, withScheduledAt,
withSingleInstance, withEnvironments, withDependsOn, withCronExpression, withMeta,
withEnabled, withIdempotencyKey.
use Daycry\Jobs\Definition\JobDefinition;
$base = Jobs::define('command', 'app:report')->named('report')->toDefinition();
// Derive variants without mutating $base.
$urgent = $base->withPriority(9)->withQueue('high');
$delayed = $base->withScheduledAt(new DateTimeImmutable('+1 hour'));
// $base is unchanged: $base->priority === 5, $base->queue === null.
Note: Because every
withXxx()returns a new object, you can build a single “template” definition and fan it out into many enqueue sites — each variant is independent.
The wire envelope¶
When a definition is dispatched, Daycry\Jobs\Queues\EnvelopeFactory::toWire() converts it into a
canonical JSON object that every backend stores identically. Centralising this guarantees that a
message enqueued through one backend has the exact same structure as one enqueued through another.
Wire shape¶
{
"job": "command",
"payload": "app:report",
"queue": "reports",
"priority": 5,
"maxRetries": 3,
"attempts": 0,
"name": "daily-report",
"identifier": "1d4f...",
"idempotencyKey": null,
"schedule": "2026-06-10 09:00:00",
"_sig": "9a3b...hmac-sha256-hex..."
}
Wire field |
Source |
Notes |
|---|---|---|
|
|
The handler key to resolve at consume time. |
|
|
The handler’s input. |
|
|
Target queue (defaults to |
|
|
Normalised priority. |
|
|
Drives the worker’s retry decision. |
|
(worker-managed) |
Completed runs before the current one (0-based). Starts at |
|
|
Logical name. |
|
backend-assigned id |
Unique id for traceability. |
|
|
Opt-in dedup key ( |
|
|
|
|
|
HMAC-SHA256 over the immutable identity fields only. |
Signature scope¶
The _sig HMAC is computed over a deterministic JSON of the immutable identity fields:
job, payload, queue, priority, maxRetries, name, identifier, idempotencyKey. The
mutable attempts and schedule fields and _sig itself are excluded so the signature survives a
requeue (when attempts is incremented). The worker re-verifies _sig after fetch and rejects any
message whose signature is missing or invalid.
Warning: Anything that contributes to the signature is what the worker trusts. Because
attemptsis excluded, a backend can safely bump the retry counter on redelivery without invalidating the signature — but it also meansattemptsis not itself tamper-evident. Treat it as advisory and keep handlers idempotent. See Advanced topics and Configuration for the signing key.
The JobEnvelope/JobLease objects that wrap the wire payload on the consume side are documented in
Queues & Backends; this page covers only what the producer puts onto the wire.
Field reference and behaviour¶
priority¶
Higher numbers run sooner. The default is 5. Enforcement depends on the backend’s native priority
support (e.g. Beanstalk has priorities; some backends approximate ordering). See
Queues & Backends.
scheduledAt vs cron¶
These answer different questions:
scheduledAt(DateTimeImmutable)— a one-shot “do not run before this instant”. It is serialised onto the wire asscheduleand used by delay-capable backends to hold the message until due.cron(...)/ frequency helpers — a recurring schedule stored ascronExpressionon the definition. It is evaluated by the cron runner (jobs:cronjob:run) to decide when a scheduled job is due; it does not travel on the queue wire.
// One-shot: enqueue now, run no earlier than the given time.
Jobs::define('command', 'app:remind')
->scheduledAt(new DateTimeImmutable('+30 minutes'))
->queue('reminders')
->dispatch();
// Recurring: register with the scheduler; jobs:cronjob:run enqueues/runs it when due.
$scheduler->define('command', 'app:report')->named('report')->everyXMinutes(15)->queue('reports');
dependsOn and environments¶
dependsOn is used by the scheduler’s topological sort: jobs are executed in dependency order, and a
circular or unknown dependency throws. environments is checked against the current CI4 environment;
jobs not listed for the active environment are skipped. Both are detailed in
Dependencies and Scheduling.
enabled¶
A disabled definition (->disable() or ->enabled(false)) is skipped by the cron runner. This lets
you keep a scheduled job declared but inactive without deleting its registration.
idempotencyKey and singleInstance¶
These guard two different problems:
idempotencyKeyprevents the same logical message from being processed twice (at-least-once delivery can redeliver). It is opt-in:EnvelopeFactory::toWire()writesidempotencyKeyonto the wire and includes it in the signed identity fields, so tampering with the key breaks signature verification. When a wire message carries the key, the worker consultsIdempotencyGuardand acks-without-running a repeat seen withinidempotencyTtl(statusskipped-idempotent). Because delivery is at-least-once and the dedupe is best-effort under crash/redelivery, still make your handlers idempotent.singleInstanceprevents two runs at the same time of the same named job.JobRuntimeacquires a cache-backed ownership lock; contention surfaces as a failed result (so the worker can requeue it to run once the holder finishes).
Jobs::define('command', 'app:rebuild-index')
->named('rebuild-index')
->singleInstance() // never overlap
->idempotencyKey('rebuild-index') // deduplicated on the worker within idempotencyTtl
->queue('search')
->dispatch();
Both mechanisms are explained in depth — including their best-effort atomicity caveats — in Advanced topics and Advanced topics → Single instance.
See also¶
Handlers — the handler contract, the five built-ins, and writing your own.
Queues & Backends — the
QueueBackendcontract and the five backends.Scheduling — the cron runner and frequency semantics.
Retries — backoff strategies and retry semantics.
Configuration — every option in
Config\Jobs.Advanced topics — idempotency, signing, single-instance and extensibility.