pdffiller/qless-php
qless-php is a PHP client for Qless, a Redis-backed job queue. It lets Laravel/PHP apps enqueue, schedule, and process background jobs with priorities, recurring jobs, retries, and real-time monitoring.
Install the bindings with Composer and point them at a Redis instance — Qless has no server daemon of its own; Redis is the queue.
composer require pdffiller/qless-php
There is no migration, no schema, no config file. The only prerequisite is a reachable Redis (Qless keys are namespaced under ql:), so you can be enqueuing in a single script:
<?php
require 'vendor/autoload.php';
$client = new Qless\Client('tcp://127.0.0.1:6379');
$queue = $client->queues->get('default');
$jid = $queue->put('Acme\\Jobs\\SendEmail', ['user_id' => 42], ['priority' => 10]);
$job = $queue->pop(); // non-blocking reserve
$payload = $job->data; // decoded JSON payload
// ...do work...
$job->complete(); // removes the job and records it
Where to look first, in order:
Qless\Client — the connection and the entry point to queues, workers, jobs. Almost everything hangs off this object, so bind it once in your container.Qless\Queue — put, pop, recur, stats, length. This is 80% of producer-side code.Qless\Job — jid, data, complete, fail, heartbeat, cancel, retry. This is 80% of worker-side code.src/lua directory). They are the real contract with Redis and the best place to look when behaviour surprises you — everything Qless does atomically is defined there.The natural first use case is a small out-of-band worker: an HTTP request pushes a job, a CLI process pops it, does the work, and calls complete() or fail(). Get that loop working end-to-end before adding scheduling, dependencies, or recurring jobs.
Separate producer and consumer processes. Producers are usually inside your web app: a controller, a model observer, or a queued command injects Qless\Client, grabs a queue by name, and calls put(). Workers are long-lived CLI scripts — an Artisan command, a Symfony console command, or a plain PHP file — that loop. Never pop a job from inside a web request; the lock and heartbeat semantics assume a worker owns the job for a while.
Use a callable/class-name dispatch table, not serialized objects. The payload that goes into Redis is JSON, so pass scalars and arrays only, and let the worker rehydrate:
// producer
$queue->put('SendEmail', ['user_id' => 42, 'template' => 'welcome']);
// worker
$handlers = [
'SendEmail' => function (Qless\Job $job) {
$user = User::find($job->data['user_id']);
Mailer::send($user, $job->data['template']);
},
];
while (true) {
$job = $queue->pop();
if (!$job) { usleep(200000); continue; }
try {
$handlers[$job->data['_class'] ?? $job->klass]($job);
$job->complete();
} catch (Throwable $e) {
$job->fail(get_class($e), $e->getMessage());
}
}
The klass string you pass to put() is stored verbatim and is not validated, so a typo silently produces a job nobody can handle — pair the names with a constant or an enum.
Heartbeat long jobs. A popped job is locked for a TTL (default on the order of a minute). If your work takes longer and you never heartbeat, Qless assumes the worker died, returns the job to the queue, and another worker starts the same job. Any job that can exceed the lock TTL needs $job->heartbeat() on a timer — typically interleaved with the work itself, or in a pcntl_alarm/SIGALRM handler.
Scheduling and retries go through options on put(). delay (seconds) defers a job, and the same job-creation entry point accepts priority, tags, and depends for dependencies on other jids. For retry, prefer re-enqueueing from the failure path with an explicit backoff over relying on implicit re-pop, and keep an attempt counter in the payload — Qless tracks failures per job, but once you re-put you get a fresh jid and a fresh history.
Recurring jobs use recur() on the queue, not an external cron. Register them once (it is idempotent per class+args+interval in Qless's Lua) and let Qless materialise each occurrence. This is the cleanest win over a hand-rolled cron + table approach: it is server-clock based, so worker/host skew does not matter.
Bind the client once. In a Laravel service provider, register Qless\Client as a singleton with the DSN from config and inject Qless\Queue instances via a small factory. The client is stateless enough to share, and creating a new one per job wastes connections. For test isolation point the DSN at a dedicated Redis database (.../15) and flush the ql:* keys in setUp.
Scale out with processes, not threads. PHP has no threads, so run N copies of the worker script under Supervisor/systemd, each with a distinct worker name (Qless tracks workers for visibility and for unfail/track tooling). Inside the worker, cap memory by exiting after N jobs (or pcntl_fork per job) — long-running PHP daemons leak.
kill -9 all cause re-delivery. Assume every job runs at least once and make handlers idempotent (unique keys, INSERT ... ON CONFLICT, conditional updates).complete() or fail(). An uncaught error leaves the job locked until the TTL expires, then it re-pops and fails again — you get a slow-motion infinite retry with no error surfaced anywhere. Always wrap in try/catch/finally.DateTime objects do not. Format dates to strings and store IDs rather than models. Very large payloads bloat Redis memory and every pop copies them — keep them small.$job->jid, $job->data) or getters ($job->getJid(), $job->getData()). If a property read returns null, read Qless\Job before assuming the job is empty.ql:* keys and FLUSHDB will destroy every outstanding job. Sharing a Redis instance with cache data is fine; sharing a database with something that flushes is not.redis-cli. SCAN --pattern 'ql:*' shows the layout: ql:queues (set), ql:queue:<name> (sorted set), ql:job:<jid> (hash), ql:workers, ql:locks, ql:tags. When a job "vanishes", HGETALL ql:job:<jid> tells you whether it is complete, failed, or waiting on a dependency in seconds.autorestart + a restart hook) or exit-on-drain.pop() is non-blocking in most ports. A tight while (true) { pop(); } will hammer Redis with thousands of calls a second and show up as CPU on the Redis box. Sleep a few hundred milliseconds when the pop is empty.TIME inside its Lua for scheduling decisions. Compute delays as offsets (delay => 300), not absolute timestamps from time() on a worker, or a skewed worker host will schedule jobs into the past or the far future.composer.json and upgrade deliberately rather than floating.$job->cancel() (discard), $job->retry() (re-enqueue from the failure path), $client->jobs->get($jid) (inspect by id), tag queries via $client->jobs->tagged(...), and $queue->stats() for backlog/latency dashboards. Building a tiny health endpoint over stats() on day one pays for itself the first time a queue backs up.How can I help you explore Laravel packages today?