macpaw/symfony-messenger-bundle
MacPaw's Symfony Messenger bundle streamlines integrating Symfony Messenger into PHP applications, with configuration for transports, message buses, handlers, and worker commands so you can dispatch messages and process background jobs asynchronously with minimal boilerplate.
This bundle is a thin integration layer between Symfony's Messenger component and the framework's DI container. Because the package is small and sparsely documented, treat the source as the spec: read README.md, then src/DependencyInjection/Configuration.php and the extension class to learn the real config keys and defaults before wiring anything. Note that this is a Symfony bundle: it has no Laravel service provider, so in a Laravel codebase it is not installable — the equivalent role is played by the queue/ShouldQueue job system, and the Messenger concepts below (transports, middleware, retry, failure transport) map onto Horizon/queues rather than onto anything this package provides.
Minimal setup:
doctrine/orm 3.x. Run composer why-not doctrine/orm ^3.0 and composer show doctrine/orm before requiring, because Composer will either force an ORM major upgrade across your app or refuse to install.composer require macpaw/symfony-messenger-bundle:1.1.0 — pin the exact version; this pulls symfony/messenger (and symfony/serializer if not already present).config/bundles.php if Symfony Flex didn't do it.config/packages/<bundle_alias>.yaml using the alias declared in the bundle's extension class. If the bundle defers to Symfony's own tree, your familiar framework.messenger/messenger: config is what actually applies — confirm this first, because it changes everything downstream.bin/console debug:config <alias> and bin/console debug:messenger to see what got registered.Where to look first:
composer.json of the bundle — confirm whether doctrine/orm is a hard require or only require-dev; the 1.1.0 upgrade only matters to you if it's actually pulled in.debug:container --tag=messenger.message_handler — confirms handler discovery actually works.debug:messenger — shows buses, transports, and routing after config load.First use case: a message + handler + async transport.
final class SendWelcomeEmail { public function __construct(public readonly string $userId) {} }
#[AsMessageHandler]
final class SendWelcomeEmailHandler
{
public function __invoke(SendWelcomeEmail $message): void { /* ... */ }
}
Dispatch with MessageBusInterface::dispatch($message), route the class to your transport, then bin/console messenger:consume async -vv. If that loop works end-to-end, the bundle is doing its job; if handlers aren't found or the bus is missing, the problem is almost always config alias mismatch or service autoconfiguration, not Messenger itself.
Dispatch-then-forget from controllers, await results from commands. In HTTP context, dispatch to an async transport and return; the request shouldn't block on bus work. In console commands or tests, dispatch to the sync transport (or call $bus->dispatch() with an in-memory transport) so failures surface immediately.
Bus selection. If the bundle registers more than one bus (command/query/event), inject the specific one by its service id rather than relying on MessageBusInterface autowiring, which resolves to the default bus. Verify ids via debug:container MessageBusInterface.
Middleware is the extension point you'll actually use. Implement Symfony\Component\Messenger\Middleware\MiddlewareInterface, register as a service, and tag it (messenger.middleware or whatever tag the bundle's compiler pass reads — check src/DependencyInjection/). Typical additions: correlation-id stamping, tenant context propagation, logging envelope metadata, and dead-letter classification.
Retry and failure transport. Configure retry_strategy per transport (max_retries, delay, multiplier) and a failure_transport. Operationally, messenger:failed:show / :retry / :remove become your runbook. For idempotency, make handlers safe to re-run — Messenger retries after partial failures, so any non-idempotent side effect (emails, payments, external API writes) needs a dedupe key persisted before the effect.
Serialization. Default PhpSerializer is convenient but pins you to PHP and breaks across deployments when class shapes change. For long-lived queues, switch to symfony/serializer with JSON and explicit normalizers for value objects and dates. Add a MessageVersion-style field or stamp if message schema will evolve while old messages are still queued.
Testing. Unit-test handlers by calling __invoke directly — no bus needed. Integration-test dispatch with an in-memory transport and assert the envelope landed: $transport->getSent() / getAcknowledged(). Wrap dispatches in try/catch (HandlerFailedException $e) and unwrap $e->getNestedExceptions() when asserting domain errors. The ORM 3 bump doesn't change any of this.
Transactions — now with ORM 3 semantics. The safe pattern is unchanged: persist + flush the aggregate in the request, dispatch the message after commit (or with DoctrineTransactionMiddleware/Symfony\Bridge\Doctrine\Messenger\DoctrineTransactionMiddleware if available), so consumers can't read a row that hasn't been written yet. What changed underneath in ORM 3:
Doctrine\ORM\Proxy\Proxy is gone. Any handler that reflected on proxies, or any middleware that serialized a lazy relation into the envelope, will behave differently — and a serialized entity carrying an uninitialized ghost is now an even worse idea than before. Serialize identifiers/DTOs, never entities.doctrine/dbal 4 comes with it, so any raw DBAL calls inside handlers (AbstractPlatform::getName(), schema-manager helpers) need auditing at the same time you upgrade.DoctrineTransactionMiddleware only works if your doctrine/doctrine-bundle and Doctrine bridge versions are new enough to declare ORM 3 support; check this explicitly, because the bundle upgrading its constraint does not guarantee your app's Doctrine bridge agrees.Version numbering does not mean what you think. The v1.1.0 release bundles a PR literally titled "Release 2.0," and the headline change is a framework-major upgrade (doctrine/orm 3.x). Pin the exact version (1.1.0), read the compare diff before bumping, and don't infer compatibility from the patch-level number.
The ORM 3 constraint is a transitive blast radius. Requiring this bundle now pulls or forces ORM 3. If you run ORM 2 in production, Composer will either solve you into a full Doctrine upgrade you didn't plan or fail. If you don't use Doctrine, check whether doctrine/orm is a hard require — if so you are installing and constraining a major ORM for no runtime benefit.
Maturity risk is still the headline. Small adoption and a short release history mean you are effectively the QA team, and the one new release is a dependency major with a confusing version label. Pin exact versions, keep the bundle's surface area small so it can be replaced with a handful of service definitions plus a compiler pass, and treat your app's own Doctrine/Symfony constraints as the authority on whether 1.1.0 is even installable.
Config alias mismatch is the most common failure. The bundle's extension alias rarely matches the Composer package name. debug:config <alias> failing with "no extension" means you guessed wrong — read getAlias() in the extension class.
Handler discovery depends on autoconfiguration. If you've disabled services: _defaults: autoconfigure or are registering handlers with explicit tags, the bundle's convention-based tagging won't fire. Symptom: No handler for message X at dispatch time even though the class exists. Fix with an explicit messenger.message_handler tag or re-enable autoconfigure for the handler namespace.
Middleware order is not alphabetical. SendMessageMiddleware must run near the end (it's the one that actually sends), and your custom middleware must sit before it if it mutates the envelope. This matters more with DoctrineTransactionMiddleware in the stack — it has to wrap the send, and a reordering regression shows up as "consumer sees a row that doesn't exist yet." Verify with debug:container --tag=messenger.middleware and check priorities, not registration order.
HandlerFailedException hides the real error. Symfony wraps every handler exception. When debugging a consumer, -vvv on messenger:consume and --no-reset help, but the nested exceptions are what you want; unwrap them in tests and in your failure handler. With ORM 3, a lot of old ORM 2 errors (proxy init failures, mapping driver errors) surface here as generic failures, so always unwrap before filing a bug against the bundle.
Consumers need a process manager. messenger:consume is a long-running process and will not restart on deploy. Supervisor/systemd with --time-limit and graceful shutdown (SIGTERM) is not optional. The --memory-limit flag is your guard against leaks in long-running workers — worth tightening after an ORM major, since EntityManager behaviour around stale identity maps changes between majors.
Serialization drift bites at deploy time. If you use PhpSerializer and add a constructor argument to a message class, queued messages will fail during unserialization. Drain queues before deploying breaking message changes, or move to a versioned JSON serializer now. This is doubly true for a release that changes your Doctrine version, because any message class touching an entity indirectly changes shape.
Cache is sticky. Changing bundle config or upgrading Doctrine requires bin/console cache:clear (or cache:warmup) in every environment; container compilation caches the resolved config tree and routing, and stale compiled containers after a dependency major produce failures that look like code bugs.
Extension points to lean on: decorate MessageBusInterface for cross-cutting concerns at the bus level; a custom TransportFactory if you need a transport the bundle doesn't ship; a Stamp + middleware pair for per-message metadata; and messenger:failed commands for operational recovery. These are all stable Messenger APIs — using them keeps your coupling to this bundle minimal, which, given its adoption profile and the shape of this release, is the right posture.
How can I help you explore Laravel packages today?