Queue
Queue
UglyDuckling ships a small database-backed job queue for deferring work — sending emails, calling slow external APIs, generating reports — outside the request/response cycle. Jobs are plain PHP classes; a CLI tool (ud-queue) tracks and runs them, in the same spirit as ud-migrate.
There is no external broker (Redis, RabbitMQ, SQS, …) — jobs are rows in a table, applied through the PDO connection your application already has. This keeps the moving parts small; if you outgrow it, the QueueJob/QueueRepository/QueueWorker classes are a natural boundary to swap for something bigger later.
Job skeleton
A job is a plain class extending QueueJob. It holds its own data as constructor-set properties, and knows how to turn that data into a JSON-safe array and back — this is what gets stored in, and rebuilt from, the database, instead of relying on PHP’s serialize()/unserialize() on arbitrary objects.
use Fabiom\UglyDuckling\Framework\Queue\QueueJob;
class SendWelcomeEmailJob extends QueueJob {
public function __construct(private int $userId) {
}
public function handle(): void {
// send the email, e.g. via a Mailer/DAO fetched however your app wires dependencies
}
public function toPayload(): array {
return ['userId' => $this->userId];
}
public static function fromPayload(array $payload): static {
return new static($payload['userId']);
}
}Pushing a job
Jobs are queued from application code through QueueRepository, not from the CLI — the same way migration files are authored as code, not typed at the command line.
use Fabiom\UglyDuckling\Framework\Queue\QueueRepository;
$repository = new QueueRepository($pdo);
$repository->push('emails', SendWelcomeEmailJob::class, ['userId' => $userId]);push() takes:
| Parameter | Meaning |
|---|---|
$queue |
Name of the queue, e.g. 'emails', 'reports'. Workers only pull jobs from the queue they’re told to work. |
$jobClass |
Must extend QueueJob; a class that doesn’t throws InvalidArgumentException immediately, before anything is written. |
$payload |
The array returned by toPayload() when the job is later reconstructed. |
$delaySeconds |
(default 0) Don’t make the job available for reservation until this many seconds from now. |
$maxAttempts |
(default 3) How many times to try before giving up and marking the job failed. |
Connecting to the database
ud-queue uses the same convention as ud-migrate: it looks for a migrations-config.php file in the current directory — a plain PHP file that must return a PDO instance. See Migrations » Connecting to the database if you don’t have one yet; both commands can share the same config file.
Command reference
ud-queue work [config-file] [--queue=default] [--sleep=3] [--max-jobs=0]
Runs the worker: reserves and runs jobs from --queue one at a time. With --max-jobs=0 (the default) it runs forever, sleeping --sleep seconds between polls whenever the queue is empty — this is what you point supervisor/systemd/cron at in production. With --max-jobs set to a positive number, it stops once that many jobs have been processed, or as soon as the queue empties, whichever comes first — useful for a bounded run from a cron job.
ud-queue work --queue=emails
# Working queue 'emails' (forever)...ud-queue status [config-file] [--queue=default]
Reports how many jobs are pending and how many have permanently failed on a queue:
ud-queue status --queue=emails
# Queue 'emails': 3 pending, 1 failedHow it tracks progress
A queue_jobs table is created automatically (on first work or status call) to hold pending, reserved, and failed jobs. You never need to create or touch this table yourself. A job’s row is deleted once it completes successfully — only jobs still pending, or permanently failed, stick around.
Retries and backoff
If handle() throws, the job is not lost. As long as its attempt count is below max_attempts:
- the job is released back onto the queue,
- its
attemptscounter increments, - it becomes reservable again after an exponential backoff delay (
2^attemptsseconds, capped at 60).
Once attempts reaches max_attempts, the job is instead marked permanently failed: its row is kept (not deleted) with the error message and a failed_at timestamp, and it no longer counts as pending — it stops appearing to workers, and shows up under failed in ud-queue status for inspection.
Running the worker without the CLI
QueueWorker can be used directly from PHP — useful for running a single pass as part of another script:
use Fabiom\UglyDuckling\Framework\Queue\QueueRepository;
use Fabiom\UglyDuckling\Framework\Queue\QueueWorker;
$repository = new QueueRepository($pdo);
$repository->ensureTableExists();
$worker = new QueueWorker($repository);
$worker->processNextJob('emails'); // runs a single job, returns false if the queue was empty
$worker->work('emails', sleepSeconds: 3, maxJobs: 10); // runs up to 10 jobs, or until the queue empties