lendable/symfony-messenger-polyfill
A lightweight polyfill that provides Symfony Messenger-compatible classes and interfaces, letting Laravel and PHP projects use Messenger-style message buses without requiring the full Symfony component. Useful for package authors who need interoperability while keeping dependencies minimal.
Reality check first. This polyfill exists to let you install symfony/messenger:4.2 in a project still running Symfony 3.4/4.1, where the framework.messenger config key and the Messenger bundle wiring do not exist. It is archived (last release Jan 2019), has no dependents, and targets a Symfony branch that went EOL in mid-2020. Treat it as a stopgap for a legacy app you cannot upgrade yet, or as a worked example of how the 4.2 Messenger internals fit together — not as a foundation for new work. If you can move to Symfony 4.3+/5.x, do that instead; the polyfill's whole reason to exist evaporates and you get the real integration.
Minimal steps
{
"require": {
"symfony/messenger": "4.2.*",
"lendable/symfony-messenger-polyfill": "^1.0"
}
}
Register the bundle manually. Pre-Flex Symfony has no auto-discovery, so add the bundle class shipped by the package (check the package src/ and README for the exact FQCN) to AppKernel::registerBundles(). Order matters — register it before FrameworkBundle so its compiler passes can run ahead of the framework's.
Define your bus yourself. In a 4.2-era Symfony the framework will not build messenger.bus.default for you, so declare the bus, the middleware stack, and the handler tag in services.yml.
First use case: a single command bus, one handler, one transport. Dispatch a RegisterUser message, route it to a doctrine transport, run bin/console messenger:consume in a worker. That loop is the whole point of the package — prove it end-to-end before adding a second bus or an event bus.
Where to look first: the package's DependencyInjection/ folder (that is where the polyfilled config and compiler passes live), then symfony/messenger 4.2's own MessengerPass so you understand which tags the polyfill is emulating. The README is short; the code is the documentation.
Hand-wire the bus as a service. Because framework.messenger isn't available, you build the bus explicitly. The canonical 4.2 shape:
services:
_defaults:
autowire: false
autoconfigure: false
messenger.middleware.add_bus_name_stamp:
class: Symfony\Component\Messenger\Middleware\AddBusNameStampMiddleware
arguments: ['command_bus']
messenger.bus.command:
class: Symfony\Component\Messenger\MessageBus
arguments:
- !tagged_iterator messenger.middleware
tags: [{ name: messenger.bus, bus: command_bus }]
Note !tagged_iterator is a 4.0+ feature; on 3.4 you'll need a compiler pass or Symfony\Component\DependencyInjection\Argument\IteratorArgument built in a pass. The polyfill's own bundle is a good reference for exactly this.
Register handlers with the tag the polyfill understands. Handlers are plain services implementing __invoke(YourMessage $message):
App\Handler\RegisterUserHandler:
tags:
- { name: messenger.message_handler, bus: command_bus }
If a handler is silently never called, this tag is almost always the culprit — the bus attribute must match the bus service name you tagged, not the transport name.
Use HandleTrait for synchronous query-style calls. Symfony\Component\Messenger\HandleTrait (4.2) lets you dispatch on a bus and pull the handler's return value out of the HandledStamp, which is how you keep controllers from knowing about handlers:
use Symfony\Component\Messenger\HandleTrait;
final class UserController
{
use HandleTrait;
public function __construct(MessageBusInterface $queryBus)
{
$this->messageBus = $queryBus;
}
public function __invoke(string $id): Response
{
$user = $this->handle(new FindUser($id)); // returns the handler's value
}
}
Separate buses by intent, not by transport. The 4.2 middleware is where behaviour lives: dispatch_after_current_bus for events raised inside handlers, failed_message_processing_middleware around the whole bus for retries, SendMessageMiddleware only on the bus that should actually enqueue. Keep a synchronous query_bus with no SendMessageMiddleware at all — sending queries to a transport and waiting for a reply is a trap you'll debug for a day.
Transports: config lives in your own service definitions. Doctrine, AMQP and Redis transports all exist in 4.2. Register them as services implementing TransportInterface and tag the factory with messenger.transport_factory if you add a custom one. For the serializer, messenger.transport.native_php_serializer is fine for single-app deployments; symfony_serializer is what you want the moment messages cross a version boundary or another language.
Testing. There is no TestTransport in 4.2 (that arrived in 4.3), so use the in-memory transport and assert on its getSent() / getAcknowledged() in kernel tests. For unit tests of handlers, instantiate the handler directly — do not boot a bus just to test business logic.
Integration tip: because everything is hand-wired, keep a single messenger.yml imported into config.yml, and let the polyfill's bundle own only the parts it must (compiler passes, tag aliases). Everything application-specific stays in your config so the day you drop the polyfill, the diff is confined to bootstrapping.
4.2.*, not ^4.2. Composer will happily resolve Messenger 4.3+/4.4, which ships real versions of the classes the polyfill shims. You get either a "cannot redeclare class" fatal or, worse, an autoload race where the shim wins and you're running stale interfaces against new internals. An exact constraint avoids a very confusing afternoon.src/ for the class list and confirm each is native before deleting, then drop the bundle from AppKernel.FrameworkBundle, the polyfill's compiler passes can run after the container has already resolved tagged iterators, and your transport middleware silently disappears from the bus. When a message is dispatched but never sent, check bundle order before anything else.framework: messenger: ... in a 3.4/4.1 app throws an "unrecognized option" error at cache warmup. All Messenger config is service definitions, full stop. Same for framework.messenger.transports.* examples you copy from modern docs — they will not work here.cache:clear surprisingly often because of the var/cache/dev/Container*/ app kernel cache. Use rm -rf var/cache/dev when tagging changes don't take effect.native_php_serializer, a message class that gains a constructor argument breaks consumers still running the old deploy. Consumers and producers deploy at different times; use symfony_serializer with an explicit envelope/MessageSerializer, or version your message classes and keep the old shape readable.failed_message_processing_middleware plus a RetryStrategy (max retries, delay, multiplier) or a failure_transport, otherwise a throwing handler drops the message on the floor and messenger:consume moves on.debug:messenger exists in 4.2 but reflects what the framework wired — which is nothing, in your setup. Debug at the container level instead: debug:container --tag=messenger.message_handler and debug:container --tag=messenger.middleware tell you truthfully what the polyfill produced.MessageBusInterface for cross-cutting concerns (logging, correlation IDs) instead of adding middleware late in the stack; implement TransportInterface + TransportFactoryInterface and tag messenger.transport_factory to add a transport (SQS, GCP Pub/Sub shims are the common case); implement MiddlewareInterface for anything that needs to short-circuit dispatch.TestTransport, messenger:failed:* commands, --time-limit on consumers), and no one to ask when a compiler pass interacts badly with another bundle's. Budget the Symfony upgrade as the actual deliverable and treat this package as scaffolding with a demolition date.How can I help you explore Laravel packages today?