rybakdigital/queue-bundle
rybakdigital/queue-bundle offers a lightweight foundation for configuring and managing queued jobs in PHP applications, with a simple setup for integrating background task processing into your project.
Read the source before you composer require. This is a 2019 package with 0 stars and 0 dependents, and the description says "bundle" — that's Symfony/Doctrine vocabulary, not Laravel. There is almost certainly no Illuminate\Support\ServiceProvider, no config/queue.php driver, and no php artisan queue:work integration. Treat it as a transport library you will wrap, not a drop-in queue backend.
Where to look first, in this order:
composer.json — check the php constraint and whether it pulls symfony/* packages transitively. Laravel 10/11 runs PHP 8.1+, so a ^7.1-era constraint may still resolve; a ^5.5|^7.0 one will not.src/ — find the transport interface (the thing you'll bind) and the concrete adapters. The package's public surface is essentially push($job, $priority, $delay) and a consume/work loop.tests/ — usually the only accurate documentation for a package this size. Copy the test's fake/in-memory transport for your own integration tests.README.md — last; for an unmaintained package it is frequently wrong about the current API.Minimal wiring:
composer require rybakdigital/queue-bundle
// app/Providers/QueueBundleServiceProvider.php
public function register(): void
{
// Bind the package's transport interface (verify the real FQCN in src/)
$this->app->singleton(\RybakDigital\Queue\TransportInterface::class, function ($app) {
return new \RybakDigital\Queue\Transport\YourBackendTransport(
host: config('queue.connections.bundle.host'),
port: config('queue.connections.bundle.port'),
queue: config('queue.connections.bundle.queue', 'default'),
);
});
}
First use case: offloading one slow, self-contained job (a report render, an export) where you can prove the round-trip end-to-end. Do not make your first use case "migrate the whole app off Laravel's queue" — the gap between this package and Illuminate\Queue is large (see Gotchas).
First consumer, wrapped as an Artisan command so you keep supervisor/deploy ergonomics:
class BundleWorkCommand extends Command
{
protected $signature = 'bundle:work {--once} {--max-jobs=500} {--max-time=3600}';
public function handle(TransportInterface $transport): int
{
$start = time();
$processed = 0;
$transport->consume(function ($job) {
app()->call([new $job->className, 'handle']); // decode + dispatch yourself
}, shouldStop: function () use ($start, &$processed) {
return $this->option('once')
|| ++$processed >= (int) $this->option('max-jobs')
|| (time() - $start) >= (int) $this->option('max-time');
});
return self::SUCCESS;
}
}
Adapter-first: hide it behind your own interface. Every call site should talk to your JobDispatcher, not the package. This is the single decision that makes the package replaceable (and, realistically, removable) later.
interface JobDispatcher
{
public function dispatch(object $job, ?int $delaySeconds = null, int $priority = 0): string;
}
Implement it once against the bundle, once against Illuminate\Bus\Dispatcher — and toggle via config. You get an instant escape hatch and a way to A/B the two in staging.
Binding pattern. Use singleton, not bind, for transports — they hold connections and (often) internal channel state. Register the adapter in a deferred provider so you don't open a socket on every web request that never queues anything.
Payload discipline: send IDs, not models. Laravel's SerializesModels, Queueable, InteractsWithQueue, ShouldQueue, and ShouldBeUnique are all Illuminate traits — the bundle knows nothing about them. Keep payloads to scalars and version them:
['v' => 1, 'type' => 'export', 'order_id' => $order->id, 'attempt' => 1]
Re-fetch the model in the handler. This also means a payload written today survives a schema change next month.
Delay and priority normalization. Laravel's ->delay() accepts a DateTimeInterface/DateInterval; most framework-agnostic transports want integer seconds or a timestamp. Normalize in exactly one place:
$delaySeconds = $job->delay instanceof \DateTimeInterface
? max(0, $job->delay->getTimestamp() - time())
: (int) $job->delay;
Priority conventions also differ (higher-is-better vs lower-is-better). Write one test asserting the mapping; don't discover it in production.
Retries: own them yourself. Don't assume the bundle's retry config maps to Laravel's $tries/$backoff/failed() callback. Carry an attempt counter in the payload, implement backoff in the wrapper, and push a fresh job on retry so the counter increments deterministically:
try {
$handler->handle($payload);
} catch (RetryableException $e) {
if ($payload['attempt'] >= 3) {
$this->dispatchToDeadLetter($payload, $e);
return;
}
$dispatcher->dispatch($payload, delaySeconds: 2 ** $payload['attempt']);
}
Bridge the lifecycle into Laravel events. Emit Illuminate\Queue\Events\JobProcessing / JobProcessed / JobFailed from your consumer, or your own equivalents, so existing listeners, logging, and metrics keep working. It costs ten lines and saves every observability tool you already own.
Testing. Bus::fake() and Queue::fake() will not intercept this transport, and assertPushed() won't see anything. Fake your JobDispatcher interface instead, and keep one integration test that runs against the real transport (a local backend or the package's in-memory fake from tests/).
Pilot with shadow traffic. Run the bundle in parallel with your current queue for a week: dispatch to both, consume only from the old one, and diff results. This catches serialization and idempotency bugs before they cost you a job.
queue.php connection driver, and no artisan queue:work. Everything in the consumer command above is glue you write and maintain. Budget for that honestly.composer.json, commit the lockfile, and if it's load-bearing, git clone it into packages/ as a path repository so you can patch it. Run composer outdated and PHP 8.2+ deprecation checks (dynamic properties, ${var} interpolation) before assuming it installs cleanly.queue:work/queue:listen, --tries, --backoff, --timeout, --memory, the failed_jobs table, queue:retry, queue:failed, queue:flush, Horizon, job middleware, WithoutOverlapping, RateLimited, job batching/chaining, and Bus::fake(). Enumerate which of these you actually need before adopting; for most apps the answer is "all of them" and the package is the wrong tool.stopwaitsecs. Laravel's worker traps signals and finishes the current job; a naive while (true) loop will not. At minimum, check a flag set by pcntl_signal between jobs and call pcntl_async_signals(true).DB::disconnect() and gc_collect_cycles() every N jobs, and cap the loop with --max-jobs/--max-time so supervisor restarts it. Laravel's queue:work does this for you; queue:listen doesn't, which is why it's the slow-but-safe option..env and inject them at construction from config(). Never commit its config file; never let it read $_ENV directly.Log via a small adapter — otherwise worker errors vanish into stderr and you're grepping supervisor logs at 2am. Log the transport name, queue name, job class, attempt number, and job ID on every failure. Job ID is what makes a stuck queue debuggable.Illuminate\Bus, define your own push() interface in that library and write adapters for both the bundle and Laravel's dispatcher. You get the decoupling without the dependency.QUEUE_CONNECTION=database|redis|sqs plus Horizon and move on. Reach for this package only when you must bridge pre-existing non-Laravel queue infrastructure, or when a shared package cannot depend on Illuminate. In that case, adopt it behind your own interface, pinned, forked, and with an exit plan written down first.How can I help you explore Laravel packages today?