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:

  • every is a single-unit duration: 30s, 5m, 2h, or 1d. It counts real

seconds, so it drifts by an hour across a daylight-saving change.

  • cron is 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.