bunqueue / client
Official PHP client for the bunqueue job queue server (native TCP protocol, msgpack)
Requires
- php: >=8.1
- rybakit/msgpack: ^0.9
Requires (Dev)
- giorgiosironi/eris: 1.1.0
- phpunit/phpunit: ^10.5
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
bunqueue/client (PHP)
The official PHP client for bunqueue, the high performance job queue server.
Native TCP protocol (msgpack, length-prefixed frames), one runtime dependency, verified certificate TLS.
Producer-friendly for FPM, worker-friendly for CLI: run() for daemons, runOnce() for cron/request-scoped batches.
Documentation · Protocol spec · Server · Changelog
The bunqueue server runs on Bun, distributed as a binary or a Docker image. This client lets any PHP service produce and consume jobs against it: one queue, any language.
Installation
composer require bunqueue/client
Requires PHP 8.1+. Single dependency: rybakit/msgpack (pure PHP, no
extension needed).
Quick start
Start a server (bunx bunqueue start or the Docker image), then:
use Bunqueue\Queue; use Bunqueue\Worker; // Producer (an API endpoint, a controller, anywhere) $queue = new Queue('emails', ['host' => 'localhost', 'port' => 6789]); $job = $queue->add('welcome', ['to' => 'user@example.com'], ['attempts' => 3]); // Worker (a CLI process: php worker.php) $worker = new Worker('emails', function (Bunqueue\Job $job) { sendEmail($job->data()['to']); return ['sent' => true]; }, ['host' => 'localhost', 'port' => 6789]); $worker->on('completed', fn ($job, $result) => printf("done %s\n", $job->id())); $worker->on('error', fn ($e) => error_log($e->getMessage())); $worker->installSignalHandlers(); // SIGTERM/SIGINT -> graceful stop $worker->run(); // blocking loop
Job names are protocol metadata, separate from user payloads. Job::name()
reads top-level name; Job::data() returns the submitted mixed value
unchanged, including an associative name, list, scalar, or null. Legacy
data.name envelopes remain readable. Scheduler templates likewise send the
spawned name through jobName and keep data untouched.
The client negotiates protocol v3 and advertises separate-job-name in Hello.
Worker terminal events follow broker authority. If a timeout finalizes the
lease while the processor is still running, the broker returns a successful
applied: false ACK/FAIL outcome. The Worker releases the held lease without
emitting completed/failed, incrementing its terminal counters, or turning
that expected no-op into an error event.
Request-scoped consumption (FPM, cron)
PHP often cannot run a blocking daemon. runOnce() pulls and processes one
batch, then returns — perfect for a cron tick or a protected endpoint:
$handled = $worker->runOnce(); // returns how many jobs were processed
Failure semantics
use Bunqueue\UnrecoverableError; $worker = new Worker('orders', function ($job) { if (!isValid($job->data())) { throw new UnrecoverableError('malformed order'); // no retries -> DLQ } throw new \RuntimeException('transient'); // retried per attempts/backoff });
Retries, backoff, priorities, delays, stall detection and the dead letter queue all live in the server; the failure's message and stack (throw site first) are persisted with the job.
Long job? The PHP worker is single-threaded, so renew the lease from inside
the processor: $job->extendLock(60_000);
API surface
| Area | Methods |
|---|---|
| Produce | add, addBulk (custom ids preserved), full wire job options (priority, delay, attempts, backoff, jobId, deduplication, dependsOn, lifo, durable, ...) |
| Query | getJob, getJobByCustomId, getJobs, getState, getResult, getProgress, waitForJob, getJobCounts, count, getJobLogs, getChildrenValues |
| Control | pause, resume, isPaused, drain, clean, obliterate, remove, discard, promote, retryJob, changePriority, changeDelay, updateJobData, moveJobToFailed |
| DLQ | getDlq, retryDlq, purgeDlq |
| Schedulers | upsertJobScheduler (cron pattern or every, execution limit), getJobScheduler, getJobSchedulers, removeJobScheduler |
| Admin | webhooks, setRateLimit(limit, durationMs, ttlMs), getWorkers, getStats, listQueues, ping |
| Flows | FlowProducer: atomic parent/child trees, addChain, getFlow |
TLS: ['tls' => true] (system CAs, verified) or
['tls' => ['caFile' => './ca.pem']]. Auth: ['token' => '...'].
Atomic flows
FlowProducer validates and resolves the complete graph locally, then submits
exactly one PUSHF command. A rejected plan performs no broker I/O; a rejected
commit exposes no partial tree and needs no client-side rollback.
use Bunqueue\FlowProducer; $flows = new FlowProducer(['host' => 'localhost', 'port' => 6789]); try { $tree = $flows->add([ 'name' => 'publish-release', 'queueName' => 'release', 'opts' => ['jobId' => 'release-2026-07-30'], 'children' => [ ['name' => 'build', 'queueName' => 'build'], ['name' => 'test', 'queueName' => 'test'], ], ]); printf("root=%s children=%d\n", $tree->job->id(), count($tree->children)); $ids = $flows->addChain([ ['name' => 'extract', 'queueName' => 'etl'], ['name' => 'transform', 'queueName' => 'etl'], ['name' => 'load', 'queueName' => 'etl'], ]); } finally { $flows->close(); }
Flow data cannot overwrite name or __* markers. parentId, dependsOn
and childrenIds are owned by the planner, while repeat, deduplication and
debounce are rejected because they cannot be composed safely into the atomic
graph. Custom jobId values are sent as customId; generated IDs are portable
lowercase hex without the protocol's : separator. See
INVARIANTS.md.
A timeout after PUSHF cannot prove whether the broker committed. If the
caller may retry a production flow, assign a stable explicit jobId to every
node and reuse the same graph. If the first call did not commit, the retry can
create it; otherwise strict PUSHF collision checking returns already exists.
Treat that error as a reconciliation signal and query the known IDs; the SDK
does not rewrite the graph or fabricate successful snapshots.
Telemetry
Pass an optional payload-free callback to Queue, Worker or Connection:
$queue = new Queue('emails', [ 'onEvent' => function (array $event): void { error_log(sprintf( '%s command=%s duration=%.2fms error=%s', $event['type'], $event['command'] ?? '', $event['durationMs'] ?? 0, $event['error'] ?? '', )); }, ]);
It receives connected, reconnect, auth, command, timeout, error
and close events without tokens or command payloads. Callback exceptions are isolated
from queue correctness.
Quality assurance
Every change runs the e2e suite (a real server spawned per run) and the cross-language conformance suite:
composer install composer test:property # Eris + PHPUnit, shrinking php tests/run-e2e.php # property tests, then e2e BUNQUEUE_SDK_SOAK_SECONDS=3600 php tests/soak.php # sustained profile cd ../conformance && bun runner.ts --driver "php drivers/php.php" # 18/18 cd ../.. && bun run test:sandbox:sdk
Mutation testing is a separate PHP 8.4 job because Infection 0.34.1 requires
PHP ^8.3; normal installs remain compatible with PHP 8.1–8.4:
curl -fsSL https://github.com/infection/infection/releases/download/0.34.1/infection.phar \ -o infection.phar echo '4e8f4235742784f45f2b883a64767a1533c58efcb9f5bd60c62cc6f50fd14035 infection.phar' \ | sha256sum -c - pecl install pcov-1.0.12 composer mutation
The mutation surface is limited to the pure flow planner and authoritative
snapshot validator. The 99% MSI ratchet and reports are configured in
infection.json5; outputs land in build/infection.log,
build/infection.html, build/infection.json, and
build/infection-summary.json.
The native suite includes multi-process custom-id and single-lease races,
fixed-seed generated payloads, malformed depth fuzzing, a 512-job spike, and
SIGKILL/reconnect durability. The soak profile reuses one connection; adjust
BUNQUEUE_SDK_SOAK_BATCH for stress diagnostics.
Maintainers should read the runtime invariants, the module and protocol guide, and the local agent rules before changing behavior.
License
MIT. See the LICENSE file. Documentation: bunqueue.dev/guide/sdks. Issues and feature requests: GitHub issues.