jms/job-queue-bundle
A Symfony bundle that adds a persistent, Doctrine-backed job queue to your application. It supports job scheduling, dependencies, retries, parallel workers, and a web interface for monitoring and managing queued jobs.
Reality check first: jms/job-queue-bundle is a Symfony bundle, not a Laravel package. It has no service provider, no facade, no config file in the Laravel sense. For a Laravel developer the honest "first step" is: confirm whether you are actually being asked to work in a Symfony codebase. If not, close this tab and use Laravel's native stack (php artisan queue:work, Bus::dispatch, schedule(), Horizon) — it covers the same ground with zero friction.
If you are in a legacy Symfony app (this is where the package still lives in the wild):
composer require jms/job-queue-bundle — note the last release was 2019-09-09, so you're pinning to Symfony 3.x/4.x and Doctrine 2.x era constraints.config/bundles.php (or AppKernel::registerBundles()), and enable the JMSSerializerBundle and DoctrineBundle companions it expects.doctrine:schema:update --force (or generate a migration) — the bundle ships its own Job and JobRelatedEntity entities that you must persist into your own database.jms-job-queue:run (the worker daemon) and jms-job-queue:list (inspect state). These two are your queue:work and Horizon equivalents.First use case — the canonical one: you have a slow Symfony Console command (a report generator, an export, a sync) and you want to run it detached. Instead of shelling out with &, you hand it to the bundle and let a daemon poll the Doctrine table:
// In a controller/service
$job = $this->get('jms_job_queue.job_manager')->enqueueCommand(
new GenerateReportCommand(),
['--year' => 2024]
);
// A worker started with `bin/console jms-job-queue:run` picks it up.
That's the whole value proposition — the same as Laravel's GenerateReport::dispatch(['year' => 2024]), minus the ergonomics.
Command-as-job wrapping. The bundle's core pattern is "console command in, background job out." Laravel developers should map this mentally to job classes, not commands: the bundle is doing Bus::dispatch(new RunCommand($cmd)) under the hood. If you're integrating the two ecosystems (rare, but happens during a migration), the cleanest seam is an HTTP or Redis boundary — the Laravel side dispatches a Laravel job, that job invokes the Symfony command in a subprocess via Symfony\Component\Process\Process. Do not try to share the Doctrine Job table with a Laravel worker; the schemas and state machines are unrelated.
Custom job classes with related entities. The bundle's distinguishing feature versus Laravel's queue is JobRelatedEntity — you can attach domain entities to a job and query "all jobs touching order #123" from the UI or code. That's genuinely useful for admin dashboards. In Laravel you'd emulate it with a polymorphic job_relations table plus Queue::before/after events; the bundle gives it to you for free. If you're porting, that's the one idea worth copying.
Daemon under Supervisor. Both stacks converge here: run bin/console jms-job-queue:run (Symfony) or php artisan queue:work (Laravel) under Supervisor/systemd, with restart-on-failure and a --time-limit/--max-time so long-lived PHP processes don't leak memory. The bundle's run command has options like --max-runtime and --max-concurrent-jobs (for parallel workers); read --help before tuning — the flag names are not the same as Laravel's.
Scheduling. The bundle itself is a queue, not a scheduler. Scheduling is delegated to JMSJobQueueBundle's companion or to cron invoking jms-job-queue:run on an interval. Laravel developers will find this a step backwards from ->dailyAt('03:00') in routes/console.php. In Laravel, prefer:
Schedule::command('reports:generate')->dailyAt('03:00')->onOneServer();
State observation. Jobs move through PENDING → RUNNING → FINISHED/FAILED/CANCELED. Poll via jms-job-queue:list or query the Job entity's repository. There's no jobs/failed_jobs split table and no built-in retry UI — you inspect state yourself. Laravel's failed_jobs table plus queue:retry is substantially more mature; note the gap if you're evaluating a migration.
Integration tip for cross-stack teams. If a Symfony service owns the job queue and a Laravel app needs to trigger work, expose a minimal endpoint (POST /internal/jobs) that calls enqueueCommand and returns the job ID. The Laravel side wraps that in a thin HTTP job with retries and backoff — you get Laravel's retry semantics on top of the Symfony executor.
Abandonment is the headline gotcha. Last release 2019 means no Symfony 5/6/7 support, no PHP 8.x guarantees, and Doctrine DBAL 3 incompatibilities. Any new project adopting it is taking on a maintenance liability. Dependents: 0 in the package metadata reflects this — it is not a living dependency you can lean on.
It owns your schema. The bundle's entities ship inside the bundle. You cannot easily add columns, change indexes, or alter the state machine without forking or subclassing with Doctrine's ResolveTargetEntityListener. This is the polar opposite of Laravel's convention where the jobs table migration lives in your database/migrations and is trivially editable.
No ServiceProvider means no Laravel extension points. There's no JobQueue::extend() analogue, no middleware pipeline, no before/after/failing hooks in the Laravel sense. Extension happens via Symfony event listeners and by replacing the JobManager service definition — Symfony-flavored, and unfamiliar to most Laravel devs.
Transaction boundaries bite. Jobs are enqueued inside Doctrine's unit of work; if you enqueue during a request that later rolls back, you can end up with orphaned jobs or jobs referencing entities that never persisted. Laravel's afterCommit() on a queueable job is the equivalent guard. Check whether the version you're on enqueues eagerly or on flush — it matters.
Debugging. Start with bin/console jms-job-queue:list for state, then worker output (-v / -vv) for stack traces. There's no Horizon-style dashboard out of the box; you either read the Doctrine table directly or build a UI. Log correlation between the web request that enqueued and the daemon that executed is not automatic — inject a correlation ID into the job's arguments yourself.
Concurrency is opt-in and crude. Parallelism is achieved by running multiple jms-job-queue:run processes; there's no atomic job-locking guarantee as robust as Laravel's Redis::funnel() / ShouldBeUnique. On high-throughput tables you can get double-execution under contention. Keep concurrency low, or add a locked_at column check.
Migration guidance. If you're evaluating this because a legacy Symfony app has it and you're consolidating onto Laravel: port the command logic to Illuminate\Console\Command subclasses, wrap each in a ShouldQueue job class, replace jms-job-queue:run with Supervisor + queue:work, and replace the related-entities feature with a polymorphic pivot table. The port is mechanical and typically shrinks the codebase, because Laravel's queue handles retries, backoff, batching, and failed-job inspection that this bundle leaves to you.
How can I help you explore Laravel packages today?