yangusik/laravel-balanced-queue
Balanced queue scheduling for Laravel that spreads jobs evenly across workers to prevent bottlenecks, improve throughput, and keep processing fair. Drop-in configuration works with existing queue connections and supports priorities.
laravel-balanced-queue is a drop-in Redis queue driver that spreads work across several Redis queues instead of draining one list in FIFO order. It is aimed at the common Laravel problem of "one hot queue starves the rest" — e.g. a default queue full of slow reports blocking emails and webhooks.
Minimum setup:
composer require yangusik/laravel-balanced-queue:^1.3.3 — see the version note below; do not start on anything older than 1.3.3.php artisan vendor:publish and inspect the provider's tags) and verify the driver is registered in config/queue.php alongside your existing redis connection. Keep the stock redis connection in place so you can roll back by changing one env var.php artisan queue:work balanced-redis (use whatever connection name you registered) under Supervisor, exactly as you would a normal Redis worker.php artisan queue:restart). This driver keeps state in Redis that older worker processes will not understand.The first use case to try is a two-queue split: a high-volume default and a low-latency notifications. Confirm with redis-cli LLEN that both lists are being consumed, not just the first one.
Version note for this release: v1.3.3 is a bug-fix release, not a feature release, but the bugs it fixes are the kind that silently corrupt production behaviour. On 1.3.2 or earlier, jobs dispatched with ->afterCommit() could run before the surrounding transaction committed, maxTries was never actually reached (jobs could retry indefinitely), and expired reservations were not recovered — meaning a worker killed mid-job could strand that job permanently. Treat 1.3.3 as the minimum supported version for any new adoption.
Weighted queue config as the primary lever. Every routing decision lives in the config's queue list. Start with equal weights, then bias toward the queue with the tighter latency SLO. Treat the config as production code: change it in a PR, not on the box, since the weights change dispatch distribution for the whole fleet.
Dispatching with intent, not with onQueue everywhere. The point of the driver is that you stop hand-routing. Let jobs land on the balanced connection and express priority through the config's weighting rather than sprinkling ->onQueue('high') through the codebase. Reserve explicit onQueue() for genuine exceptions (an outbound webhook that must not sit behind batch imports).
Transactional dispatch is now safe, so use it. With the PendingDispatch::afterCommit fix in this release, the idiomatic pattern is:
DB::transaction(function () use ($order) {
$order->markPaid();
SendReceipt::dispatch($order)->afterCommit();
});
On 1.3.2 and earlier this was a coin flip — the job could pick up the order before commit and read stale state. If your codebase worked around that with DB::afterCommit() closures or dispatch-in-an-event-listener, those workarounds can now be simplified, but only after the upgrade is fully rolled out to every worker.
Retries and backoff. Because attempts are now incremented on pop (the #19 fix), $tries and $maxExceptions behave as documented. Set $tries and $backoff per job class as normal; there is no driver-specific retry API to learn. For jobs that were previously "mysteriously immortal", audit production for jobs that have been retried hundreds of times — those were masked by the old behaviour and may now start failing into your failed_jobs table loudly.
Reservation recovery as a safety net, not a substitute for --timeout. The new reserved-payload copy means killed workers get their jobs back. Pair it with a sane retry_after in config and a worker --timeout comfortably below it, otherwise a slow job will be recovered and run twice concurrently — the reserved copy makes this more likely, not less, if timeouts are misconfigured.
Testing. Queue::fake() and Bus::fake() work unchanged, since they intercept before the driver. For driver-level integration tests, run a real Redis (or a dedicated database index) and assert on list lengths and reserved-payload keys; mocking the driver internals is not worth the effort.
^1.3.3 in composer.json, and check composer.lock. Three of the four fixes in this release correct behaviour that looks like application bugs (duplicate side effects from pre-commit jobs, infinite retries, jobs vanishing after a deploy). You will waste days debugging your own code before suspecting the queue.release() and delete() no longer touch stock Redis keys. The #23 fix means the driver's Job class sets its base flags directly instead of delegating to RedisJob methods that assume Laravel's stock key layout. Practical consequence: any custom code (or third-party package) that subclasses RedisJob, or that pokes at queues:default:reserved / :delayed keys directly, will silently disagree with this driver. Keep your own tooling to the public queue API or php artisan queue:monitor.redis jobs and balanced jobs coexist. Namespace them into separate Redis databases (REDIS_QUEUE_DB) or separate connections to avoid confusion when reading LLEN/ZRANGE output.maxmemory-policy on Redis, size for roughly 2× the in-flight payload footprint and confirm allkeys-lru is not evicting queue data — eviction here means lost jobs, not lost cache.queue:work under Supervisor with queue:monitor alerting. Do not promise the team a Horizon view of a balanced queue.queue:restart in your deploy script and drain before swapping.LLEN on each configured queue to confirm distribution, (2) ZRANGE ... WITHSCORES on the reserved/delayed structures to see whether jobs are stuck in reservation, (3) failed_jobs for the previously-masked retry storms, (4) confirm every worker is on 1.3.3.How can I help you explore Laravel packages today?