levuro/job-queue-bundle
levuro/job-queue-bundle provides a lightweight, database-backed job queue for Symfony apps. Dispatch background jobs, process them with worker commands, and manage retries, failures, and delayed execution without adding external queue infrastructure.
First, verify you should be here. This is a Symfony bundle (JMSJobQueueBundle, redistributed by levuro) built on Doctrine ORM and the Symfony console. There is no ServiceProvider, no facade, no publishable config/jms_job_queue.php, and no dispatch() helper. In a Laravel app you are committing to: doctrine/orm (e.g. via laravel-doctrine/orm), a second entity manager and its migrations, a Symfony console kernel or a bespoke bridge to run the worker, and hand-wired DI for the bundle's services. If your jobs are ordinary Laravel work, Horizon + Bus::batch() covers the same ground for a fraction of the glue. Adopt this only when you specifically want jobs as durable, queryable Doctrine rows dispatchable inside the same transaction as business data, and an operator UI to inspect them.
Minimal setup
composer require levuro/job-queue-bundle:1.10.9 — pin explicitly. 1.10.9 is a maintenance patch that swaps symfony/debug for symfony/error-handler; it is safe on Symfony ≥ 4.4 and hard-breaks EOL Symfony < 4.4.jms_job_queue_job and the related-job / cron tables are generated from the bundle's entity mappings. If you don't have a Symfony kernel, generate the SQL once offline and commit it as a Laravel migration.bin/console jms-job-queue:run (or a Symfony Application you bootstrap yourself). Nothing runs until this process is alive.Where to look first, in order
JMS\JobQueueBundle\Entity\Job — the state machine, retries, exit code, output, and getRelatedEntities() all live here. Read it before anything else.jms-job-queue:run command — it is the entire runtime. Understand its concurrency and polling options.recurring_commands config block — this is the cron scheduler; you do not get cron for free from anything else.First use case to prove out
Enqueue one Symfony console command, run the worker, read the state back:
use JMS\JobQueueBundle\Entity\Job;
$job = new Job('app:sync-orders', ['--since' => '2024-01-01']);
$em->persist($job);
$em->flush(); // enqueue
// later
$job->getState(); // new / running / finished / failed / canceled
$job->getOutput(); // captured console output
$job->getExitCode();
The mental model shift: you write console commands, not Job classes. The unit of work is an argv array, not a handle() method. That is the single biggest adjustment coming from Laravel.
Transactional enqueue (the reason to choose this). Jobs are rows in the same database as your domain data, so you can enqueue atomically with the write that caused the work. From a Laravel codebase, wrap both sides:
DB::transaction(function () use ($em, $order) {
$order->save(); // Eloquent
$job = new Job('app:send-receipt', [(string) $order->id]);
$em->persist($job);
$em->flush(); // Doctrine, same connection
});
This is the property you cannot get from Redis without an outbox table. Guard against enqueuing before commit if Eloquent and Doctrine ever end up on different connections — then the ordering breaks and you must fall back to DB::afterCommit().
Pass IDs, not payloads. The job row stores argv, which is indexed and duplicated in the admin UI. Serialize ['--order-id' => 42] and reload the model inside the command. Large JSON blobs in argv will bloat the table and slow the poller.
Idempotency + dedupe. JobManager::addJob() accepts a $duplicate flag; the repository can tell you whether a matching non-finished job already exists. Use it for "one sync per account at a time" flows rather than hand-rolling a lock table.
Retry policy per job, not per worker. Set it on the instance: setMaxRetries(), setMaxRuntime(), and retry-aware backoff where exposed by your bundle version. A poison job should exhaust retries and land in failed, not spin forever — assert that in a test.
Recurring work via config, not cron. Define scheduled commands in the bundle's recurring_commands config. These become tracked job rows with history, which is the actual value: "did last night's report run, and why did it fail?" For a Laravel-only team, expose a thin Artisan wrapper so operators don't need bin/console:
// app/Console/Commands/RunJobQueue.php
public function handle() {
return \Symfony\Component\Console\Application::class // boot a minimal kernel
?: $this->call(...); // whatever bridge you build
}
Honestly: writing the bridge is the work. Budget for it.
Supervise the worker like a daemon. One process, --max-concurrent-jobs tuned to your DB connection pool, restart on exit, log to stdout. Under Laravel you'd use Supervisor with a command= pointing at your Symfony console entrypoint, not queue:work. Run a second process only if you've sized the connection pool for it.
Observability without the UI. The bundle ships an admin web UI (a Symfony route), which is inconvenient in a Laravel app. The pragmatic alternative is two Eloquent-ish queries against the job table: failure count in the last hour, and age of the oldest new job. Those two numbers catch almost every real incident.
Hook job state changes with a Symfony event subscriber on the bundle's state-change event to push metrics or notifications — this is the cleanest extension point and avoids polling.
dispatch(), no queue:work, no retry command you'd recognise. Everything is "insert a row" and "a long-lived process polls the table." Latency is poll-interval-bound, not sub-second.running when the process is killed needs manual intervention or a reaper. Know where that lever is before you need it at 3am.symfony/debug will surface as a runtime class-not-found, not an install error, if any of your code (or a bootstrap in your console entrypoint) still references Symfony\Component\Debug\*. Grep for it before upgrading. The new symfony/error-handler requirement is a no-op on Symfony ≥ 4.4 and unsatisfiable below it — if a service is stuck on Symfony 3.4/4.0–4.3, pin 1.10.8 and schedule the framework upgrade separately. Also note the maintainer ships requirement changes as patches: pin your version, don't float.failed with the right retry count and captured output. That single test covers the release-specific risk and most of the operational risk.JobManager (custom enqueue semantics), the recurring-commands config (scheduling), and getRelatedEntities() (link a job to your Eloquent records via the related-job table for support triage).How can I help you explore Laravel packages today?