Background Jobs
Some work does not belong in the request that triggers it: sending a welcome email, resizing an upload, warming a cache. Fast ships a small, opt-in background-jobs subsystem so you can hand that work off and answer the request right away. Like every Fast subsystem, it is zero-tax until you enable it.
How it works
When you enable jobs, the master process forks a single broker process, and every worker talks to it over a socket. The broker holds the queue: jobs waiting to run, jobs reserved by a worker, jobs delayed for a retry, and jobs that gave up (the dead set). Each worker runs a small poller that reserves a batch of ready jobs, runs each one in its own fiber, and reports success or failure back to the broker.
Delivery is at-least-once. If a worker crashes or a job runs past its visibility timeout, the broker hands that job to another worker. That means your handle() method must be idempotent: running it twice for the same job should be safe.
Writing a job
A job is any class that implements Fast\Jobs\Job. The one method, handle(), receives the arguments the job was dispatched with:
use Fast\Jobs\Job;
final class SendWelcomeEmail implements Job
{
public function __construct(private Mailer $mailer) {}
public function handle(array $args): void
{
$user = User::find($args['userId']);
$this->mailer->welcome($user);
}
}
The job is resolved from the container, so constructor dependencies are autowired exactly like a handler or middleware. Inside handle() you can reach for any async helper (db(), http(), kv()), because the job runs in a fiber on a worker just like a request.
The Job marker is a security boundary, not just a convention: the runner only ever instantiates classes that implement Job. A dispatched class that is missing or does not implement Job is buried immediately and never retried, so a crafted dispatch can never coerce a worker into building an unrelated object.
Enabling it
Turn it on with use_jobs() before serve():
use Fast\Jobs\JobsConfig;
use_jobs(new JobsConfig(concurrency: 20));
Every worker starts its poller automatically. On a platform that cannot fork (native Windows), each worker runs its own in-process queue instead of a shared broker, so jobs still run but are not shared across workers.
Dispatching
Reach for the dispatch() helper from anywhere in a request:
post('/signup', function () {
$user = User::create(request()->all());
dispatch(SendWelcomeEmail::class, ['userId' => $user->id]);
return redirect('/welcome');
});
The arguments cross the broker socket and are decoded with objects disabled, so pass ids and primitives, not live objects. dispatch() returns the job's id.
Delay a job or override its retry cap per dispatch:
dispatch(SendReminder::class, ['userId' => $id], delay: 3600); // run in an hour
dispatch(ChargeCard::class, ['orderId' => $id], maxAttempts: 5); // more retries
You can also reach the facade directly with jobs() for its dispatch() and stats() methods.
Retries and the dead set
When handle() throws, the broker reschedules the job with exponential backoff and tries again, up to the job's maxAttempts (default 3). Once a job has been delivered that many times, it is moved to the dead set instead of running again. This bounds poison jobs: a job that crashes every worker cannot loop forever.
A job can also fail permanently. Throw from handle() for a transient failure that should retry; a missing or non-Job class is treated as permanent and buried at once.
Scheduling recurring tasks
Some jobs should run on a cadence rather than in response to a request: a nightly report, a cache warm every few minutes, a heartbeat every thirty seconds. Declare them with schedule() at the root, next to use_jobs(). Each due occurrence is enqueued as an ordinary job and consumed by a worker, so scheduled work inherits the same retries, backoff, and dead-set behavior described above.
use_jobs();
schedule(ReportJob::class, cron: '0 2 * * *'); // daily at 02:00 local
schedule(CleanupJob::class, every: '15m', args: ['deep' => true]);
schedule(PingJob::class, every: '30s');
Give exactly one cadence per task:
everyis a single-unit duration:30s,5m,2h, or1d. It counts real
seconds, so it drifts by an hour across a daylight-saving change.
cronis a standard five-field expression, `minute hour day-of-month month
day-of-week, in the server's local time zone. It supports *, comma lists, a-branges, and/step. Because it follows the wall clock, reach for cron` when you mean a specific local time like "02:00".
A few properties worth knowing:
- It fires once, cluster-wide. With the forked broker, only the broker evaluates
the schedule, so a task fires once per due time however many workers you run. On a no-fork platform (native Windows), only the first worker evaluates it against its own in-process queue.
- It will not pile up. Each task carries a stable identity; a fresh occurrence is
skipped while a previous one is still queued or running, so a slow or failing job cannot accumulate a backlog.
- It is not durable. A restart resets each task to its next future occurrence.
Nothing double-fires, and occurrences missed while the process was down are skipped rather than replayed in a burst.
- Timing is best-effort. The scheduler checks once per
schedulerInterval(one
second by default), so worst-case latency from a task becoming due to it running is about one scheduler interval plus one poll interval. An interval shorter than the tick simply runs once per tick.
If you call schedule() without use_jobs(), nothing consumes the tasks; Fast logs a warning at startup so the mistake is visible.
Configuration
JobsConfig has a safe default for every field:
new JobsConfig(
socketDir: '/tmp/fast-jobs', // where the broker socket lives
pollInterval: 0.25, // seconds between reserve polls
concurrency: 10, // max in-flight jobs per worker
visibilityTimeout: 30.0, // seconds before a reserved job is redelivered
defaultMaxAttempts: 3, // deliveries before a job is buried
backoffBase: 2.0, // retry backoff base, in seconds
schedulerInterval: 1.0, // seconds between recurring-task checks
);
Pick visibilityTimeout comfortably above your slowest job: if a job runs longer than the timeout, the broker assumes the worker died and redelivers it (which is safe when your jobs are idempotent, just wasteful).
Draining
Jobs take part in graceful shutdown. When a worker is told to drain, its poller stops reserving new work and the worker waits for in-flight jobs to finish (bounded by the usual drain deadline) before exiting, so a job in progress is not cut off mid-run.