silpo-tech/message-bus-bundle
A Symfony message bus bundle that wires commands, queries, and events to their handlers with configurable middleware, helping decouple business logic, simplify service composition, and keep PHP applications testable and maintainable.
This is a Symfony bundle, not a Laravel package. If you're coming from Laravel, mentally map it to a stricter, container-driven version of Laravel's bus: messages are immutable DTOs, handlers are services, and middleware is a first-class pipeline configured in YAML rather than registered ad hoc.
Install and register
composer require silpo-tech/message-bus-bundle
// config/bundles.php
return [
// ...
SilpoTech\MessageBusBundle\MessageBusBundle::class => ['all' => true],
];
Look here first, in this order
README.md — usually the only real documentation for a 2-star bundle; treat it as the spec.src/DependencyInjection/Configuration.php — the authoritative list of every config key, default, and bus name. This is more reliable than the README.src/DependencyInjection/MessageBusExtension.php — shows which service tags auto-register handlers and middleware.src/MessageBusBundle.php — compiler passes reveal hidden wiring (auto-tagging, aliases).Dump the real config instead of guessing
bin/console config:dump-reference message_bus
bin/console debug:container --tag=<handler_tag_from_extension.php>
First use case: a synchronous command
// src/Application/Command/CancelOrder.php
final class CancelOrder
{
public function __construct(
public readonly string $orderId,
public readonly string $reason,
) {}
}
// src/Application/Command/CancelOrderHandler.php
final class CancelOrderHandler
{
public function __construct(private OrderRepository $orders) {}
public function __invoke(CancelOrder $command): void
{
$this->orders->get($command->orderId)->cancel($command->reason);
}
}
Wire the handler per the bundle's convention (usually a service tag or autoconfigure: true), inject the bus, and dispatch from a controller:
public function __invoke(CancelOrder $command, CommandBusInterface $bus): Response
{
$bus->dispatch($command);
return new Response(null, 204);
}
If nothing happens, debug:container the bus service and check the handler is actually tagged — auto-registration is the #1 source of "it silently does nothing."
Separate command, query, and event buses. The bundle's value is convention: one handler per command/query, many handlers per event. Keep message classes in src/Application/Command|Query|Event, handlers next to them, and never let a command return a value — return an ID and query for state.
Middleware is where the real behavior lives. Typical day-to-day stack, ordered outermost to innermost:
beginTransaction/commit/rollBack, must wrap the handler)Configure order in YAML; the bundle almost certainly exposes a per-bus middleware list. If order isn't explicit, it follows tag priority — set priority on your middleware service definition.
Custom middleware pattern
final class TransactionMiddleware implements MiddlewareInterface
{
public function __construct(private EntityManagerInterface $em) {}
public function handle(object $message, callable $next): mixed
{
$this->em->wrapInTransaction(fn () => $next($message));
}
}
Register it with the bundle's middleware tag and confirm via debug:container --tag=<middleware_tag>.
Sync by default, async by exception. Route only genuinely slow or retryable messages to a transport (Symfony Messenger under the hood). A practical split: commands that must return an HTTP response stay sync; events, exports, notifications, and third-party calls go async. Then dispatch-then-return-202 becomes your standard controller shape.
Events fan out, commands don't. Use events for cross-cutting side effects (email, audit, cache invalidation). Because every handler runs, make them idempotent and short — the bus won't deduplicate for you.
Testing pattern
__invoke() — the bus adds nothing there.Laravel-equivalent mapping (useful when porting habits): Laravel job/command → message, handle() → __invoke()/handler, dispatch() → $bus->dispatch(), queue connection → Messenger transport, Bus::fake() → a spy bus service in the test container.
It is Symfony-only. There's no service provider, no config/message_bus.php, no facade. In a Laravel app you can technically pull the bundle in for its message/handler interfaces, but every dispatch path needs hand-wiring. Realistically, vendor the pattern rather than the package: copy the interfaces and write a small Bus wrapper bound as a singleton.
Silent no-ops from tag autoconfiguration. Handlers not tagged → dispatch resolves zero handlers and either throws "no handler" or (worse, in some setups) returns null. Always run bin/console debug:container --tag=... after adding a handler directory. If you use a custom services.yaml that overrides _defaults, autoconfigure: true may be lost.
Middleware order is a correctness issue, not a preference. Transaction middleware inside validation means you open transactions for invalid messages; logging inside the transaction means your "failed" log entries get rolled back. Pin the order explicitly and cover it with a kernel test.
Async + transactions don't mix. If a message is routed to a transport, the transaction middleware won't span the HTTP request and the worker. Use the outbox pattern (persist the message row in the same transaction, relay it later) instead of assuming the bus gives you atomicity.
Debugging commands to keep in muscle memory
bin/console debug:messenger # buses, handlers, middleware per bus
bin/console messenger:failed:show # failed async messages
bin/console messenger:failed:retry <id>
bin/console debug:event-dispatcher # if events are wired via dispatcher
Add a logging middleware early — the stack traces from deep middleware chains are unreadable otherwise.
Config quirks. Bus aliases matter: injecting MessageBusInterface when the bundle defines command.bus/query.bus resolves to whichever is aliased, which is rarely what you want. Inject concrete bus service IDs. Also check whether the bundle merges or replaces the default messenger config — if it defines its own buses, the stock messenger.routing keys may need to reference the new bus names.
Extension point worth knowing. Beyond middleware, the compiler pass is the clean place to add cross-cutting auto-tagging (e.g., every class implementing your CommandHandlerInterface in src/Application). Write a tiny compiler pass in your app instead of listing services by hand.
Project maturity risk. Two stars, zero dependents, and a release date that reads as future-dated — verify the tag actually exists on Packagist and inspect the git history before standardizing on it. Pin an exact version in composer.json, and read composer.json for the Symfony/Messenger version constraints; a bundle this small often lags major Symfony releases. Have a fallback plan: since it's a thin layer over Symfony Messenger, replacing it later means swapping the bus service IDs and re-registering middleware — keep your message and handler classes free of bundle-specific types so that migration stays mechanical.
Upgrade/verification ritual. After any composer update, re-run the kernel test that dispatches one command, one query, and one event through the real container. That single test catches nearly every regression this bundle can introduce.
How can I help you explore Laravel packages today?