Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Messenger Job Batch Bundle Laravel Package

pixelplan/messenger-job-batch-bundle

A Symfony bundle that adds job batching to Messenger, letting you dispatch, track, and manage groups of related messages as batches with progress and completion handling.

View on GitHub
Deep Wiki
Context7

Getting Started

This is a Symfony bundle (Symfony Messenger + Doctrine), not a Laravel package — there is no Bus::batch() equivalent here. If you're a Laravel developer evaluating it, the mental model is: you dispatch N messages, the bundle records them against a batch row, and an event fires when the last one settles.

1. Install and register

composer require pixelplan/messenger-job-batch-bundle

As of 0.0.8 the bundle targets Symfony 5.4 through 8.x (Symfony 8 support landed in 0.0.8; earlier releases stopped at 7.x). Symfony 5.4+/6+: add to config/bundles.php:

return [
    // ...
    PixelPlan\MessengerJobBatchBundle\MessengerJobBatchBundle::class => ['all' => true],
];

(Symfony 4.x-style AppKernel::registerBundles() for older apps.)

2. Create the schema

The bundle ships a Doctrine entity for the batch record. Generate and run a migration:

bin/console doctrine:migrations:diff
bin/console doctrine:migrations:migrate

If you use a non-default entity manager, check the bundle config for the manager name before migrating — a batch table written to the wrong connection is the #1 "it never finishes" cause.

3. First use case: fan-out with a completion hook

The canonical scenario is something like "send 5,000 invoice emails, then notify accounting." Instead of a status column and a cron, you wrap the dispatch:

public function dispatchAll(array $invoices): void
{
    $batch = $this->batchManager->create('send-invoices');

    foreach ($invoices as $invoice) {
        $this->bus->dispatch(new SendInvoice($invoice->getId()), [
            new BatchStamp($batch->getId()),
        ]);
    }

    $this->batchManager->close($batch); // no more jobs may be added
}

Then subscribe to the finished event:

#[AsEventListener(event: JobBatchFinishedEvent::class)]
public function onFinished(JobBatchFinishedEvent $event): void
{
    $this->mailer->send(new BatchReport($event->getBatch()));
}

Where to look first: vendor/pixelplan/messenger-job-batch-bundle/src/ — with no meaningful external documentation, the bundle class, the DI extension (DependencyInjection/Configuration.php), and the event classes are the docs. Read those three before writing any integration code, and confirm the exact service IDs and stamp class names against the source rather than trusting any snippet. The 0.0.8 changelog was a one-line Symfony 8 compatibility bump, so nothing in the public API surface moved — but re-read composer.json after upgrading to confirm the Symfony constraint range you actually got.

Implementation Patterns

Explicit batch scope (recommended default). Create the batch, dispatch with a stamp carrying the batch ID, then explicitly close it. The explicit close() matters: it tells the tracker "no further jobs will be added," which is what lets the final job trigger completion. Skipping it means completion may only fire on a timeout sweep.

Middleware-based auto-attachment. If you always batch on a given transport or message interface, register a Messenger middleware that reads the batch ID from a stamp or from a BatchAwareInterface on the message and attaches it automatically. Keeps dispatch() call sites clean:

final class BatchStampMiddleware implements MiddlewareInterface
{
    public function handle(Envelope $envelope, StackInterface $stack): Envelope
    {
        if ($envelope->getMessage() instanceof BatchAwareInterface
            && null === $envelope->last(BatchStamp::class)) {
            $envelope = $envelope->with(new BatchStamp($envelope->getMessage()->getBatchId()));
        }
        return $stack->next()->handle($envelope, $stack);
    }
}

Chunked fan-out from a console command. For large sets, create the batch inside the command, dispatch in chunks of a few hundred, gc_collect_cycles() between chunks, and close at the end. This mirrors the common "import 200k rows" job and keeps a single batch row rather than one per chunk.

Progress without polling the DB from the UI. Track total and a settled counter on the batch entity, expose a thin controller returning {total, settled, failed, status}, and let the frontend poll that. Cache the read for a few seconds — every worker completion will be writing to the same row.

Per-transport integration. Point batches at your async transport (async, priority) and keep the batch-tracking listener on the same consumer. If you split high/low priority consumers, batches that mix both will finish at unpredictable times because the low-priority worker may not run for minutes.

Retention. Add a scheduled command that deletes settled batches older than N days. Nobody remembers to do this and the table grows forever.

Gotchas and Tips

Counter updates race under concurrency. Multiple workers settling jobs simultaneously all UPDATE the same batch row. Without an atomic increment (UPDATE ... SET settled = settled + 1 or a pessimistic lock / SELECT ... FOR UPDATE), you will lose counts and either finish early or never finish. Check how the bundle persists the counter; if it's ORM read-modify-write, wrap it or use a LOCK hint.

Retries and failures inflate or stall counts. A job that throws and is retried may emit a "handled" signal more than once, or may land in messenger:failed and never emit one — leaving the batch permanently open. Decide explicitly whether failed counts as settled, and configure or override accordingly. Also note messenger:failed:retry replays outside the normal flow; those messages usually won't be re-associated with their batch.

A batch with a lost message never finishes. Message dropped, queue purged, worker killed mid-job — the batch sits at 98%. Always implement a sweeper: a cron that marks batches with no counter movement in X hours as stalled and emits the finished/failed event. Treat the event as "probably done," not "provably done."

Doctrine entity manager staleness in long-running workers. messenger:consume clears/refreshes the EM around message handling. If you hold a batch entity reference across messages, it detaches. Re-fetch by ID inside each handler rather than injecting the entity.

Serializer mismatch breaks stamps. Symfony's PhpSerializer (default) preserves stamp objects; Symfony\Component\Messenger\Transport\Serialization\Serializer (JSON) does not, unless stamps are encoded in headers. If you switch serializers, verify the batch ID still arrives — otherwise every job settles against null and batches silently never complete.

Upgrading to 0.0.8 / Symfony 8. The release only widens the Symfony constraint; there are no migrations, deprecations, or API changes called out. That means the risk is entirely transitive: bumping the bundle can pull a newer symfony/messenger and doctrine/orm into your lockfile. On Symfony 8, messenger:consume and the Doctrine bridge have their own behavioural changes (worker lifecycle, EM clearing cadence) which are exactly the things the batch counter relies on. Treat the bundle bump as a framework bump: run your dispatch-three-messages integration test against the new lockfile before deploying, and re-verify the Doctrine increment behaviour under a real concurrent consumer — the counter code did not change, but the ORM semantics around it may have.

Config quirks to check in Configuration.php: entity manager name, table prefix, whether the timestamp/status fields are configurable, and whether completion is event-dispatched synchronously (it is, typically — so a slow listener blocks the worker). Move heavy post-batch work onto its own queued message.

Extension points worth knowing: custom event listeners on the finished/stalled events, a custom storage/manager service if you need a non-Doctrine backing store, and the middlewares list for injecting the stamp. Everything else is fairly closed.

Maturity risk. Still a 0.0.x package with minimal external adoption. 0.0.8 shows the maintainer is tracking upstream Symfony releases, which is a good signal, but a one-line compatibility commit is not the same as active feature development — there is still no evidence of a concurrency test or a documented failure/retry policy. Pin an exact version in composer.json, read the bundle's source end-to-end (it's small), and write your own integration test that dispatches three messages and asserts the event fires — that test is your real safety net against a breaking patch release, and it is the thing that will catch a Symfony 8 upgrade regression.

If you're a Laravel shop: don't reach for this. Bus::batch([...])->then(...)->catch(...)->dispatch() is native, tested, and has failedJobs, progress(), and cancellation built in. This bundle only makes sense inside an existing Symfony Messenger codebase that has no such primitive.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin
skyraptor/chatter
edlugz/sasapay
abbasudo/artisan-route