tactics/command-bus-bundle
A Symfony bundle that wires a command bus into your application: define commands and handlers, dispatch them through a single bus service, and keep controllers thin. Includes container configuration so command handling stays decoupled, explicit, and easy to test.
1. Confirm you are on a compatible Symfony version first. This bundle's last release is January 2016, which puts it in the Symfony 2.7–3.x / PHP 5.5–7.x era. Before writing any code, check its composer.json constraints. If you are on Symfony 4+ or PHP 8, the dependency resolution will almost certainly fail — in that case stop and use Symfony Messenger (or a small hand-rolled bus) instead. Only continue if you are maintaining or extending a legacy application.
2. Install and register.
composer require tactics/command-bus-bundle
Symfony 2/3 (app/AppKernel.php):
$bundles = [
// ...
new Tactics\CommandBusBundle\TacticsCommandBusBundle(),
];
Symfony 4/5 (config/bundles.php), if the constraints allow it:
return [
Tactics\CommandBusBundle\TacticsCommandBusBundle::class => ['all' => true],
];
3. Look at these three files before anything else. They define the entire contract and are the source of truth for the service id, the tag name, and the handler interface:
Resources/config/services.xml (or services.yml) — the bus service id and any middleware/decorators.DependencyInjection/*Extension.php — what gets loaded and any config keys under tactics_command_bus:.DependencyInjection/Compiler/*Pass.php — the compiler pass that finds handlers by tag and builds the command→handler map. This is where the tag name (something like tactics.command_bus.handler) lives.Verify what actually got registered:
bin/console debug:container | grep -i bus
bin/console debug:container --tag=tactics.command_bus.handler
4. First use case — one command, one handler, dispatch from a controller.
// src/AppBundle/Command/RegisterUser.php
final class RegisterUser
{
public function __construct(
public $email,
public $plainPassword
) {}
}
// src/AppBundle/Command/RegisterUserHandler.php
use Tactics\CommandBusBundle\Handler\CommandHandlerInterface; // confirm FQCN in the bundle
final class RegisterUserHandler implements CommandHandlerInterface
{
private $users;
public function __construct(UserRepository $users)
{
$this->users = $users;
}
public function handle(RegisterUser $command)
{
$user = User::register($command->email, $command->plainPassword);
$this->users->add($user);
}
}
Register the handler as a service and tag it with the tag name you found in step 3:
services:
app.command.register_user_handler:
class: AppBundle\Command\RegisterUserHandler
arguments: ['@app.repository.user']
tags:
- { name: tactics.command_bus.handler }
Then inject the bus by its service id and dispatch:
public function registerAction(Request $request)
{
$form = $this->createForm(RegisterUserType::class);
$form->handleRequest($request);
if ($form->isValid()) {
$this->get('tactics.command_bus')->handle($form->getData());
}
// ...
}
Once this loop works end to end, you have everything the bundle offers: a tagged-handler registry and a dispatcher that routes a command object to the single handler registered for its class.
Command as a DTO, handler as the only place with behavior. Keep commands immutable-ish, scalar-only, and free of services. Anything that needs the container, the entity manager, or an HTTP client belongs in the handler. This is what makes the pattern pay off — commands become trivially serializable and loggable, and handlers become unit-testable without booting Symfony.
Constructor-inject the bus, never fetch it from the container. In controllers this means adding the bus to the action's arguments or the controller's constructor; in other services, add it to services.yml. The service-locator call in the first example above is fine for a spike but fights testability.
services:
app.controller.registration:
class: AppBundle\Controller\RegistrationController
arguments: ['@tactics.command_bus']
tags: ['controller.service_arguments']
One handler per command, enforced by convention. The compiler pass builds a commandClass => handlerServiceId map. If two handlers claim the same command, whichever wins the map build is used — ordering is a compiler-pass detail, not a guarantee. Keep the mapping injective and add a code-review checklist item rather than relying on runtime detection.
Wrap cross-cutting concerns by decorating the bus, not by editing handlers. Logging, timing, transaction boundaries, and authorization checks all belong in a decorator around the bus service. This is the bundle's most valuable extension point because it requires no changes to handler code and no changes to the bundle itself:
services:
app.command_bus.logging:
class: App\CommandBus\LoggingCommandBus
decorates: tactics.command_bus
arguments: ['@app.command_bus.logging.inner', '@logger']
final class LoggingCommandBus implements CommandBusInterface
{
public function __construct(private $inner, private $logger) {}
public function handle($command)
{
$this->logger->info('command.dispatched', ['command' => get_class($command)]);
return $this->inner->handle($command);
}
}
Confirm the bus exposes an interface (CommandBusInterface or similar) before decorating; if it is a concrete final class, you will need to alias instead.
Transaction boundary pattern. For write commands, wrap the inner bus call in a Doctrine transaction inside the decorator rather than putting flush() calls in every handler. This keeps handlers focused and makes the atomicity policy uniform. If the bundle's handle() swallows exceptions, fix that in the decorator — an unreported failed transaction is the worst possible outcome.
Validate before dispatch, or validate in a middleware. A common split is: Symfony Form / validator handles input shape at the controller edge; the handler still asserts invariants on the domain. Avoid putting HTTP concerns in the handler — it should not know about Request.
Testing pattern. Two levels:
// Handler unit test — no container, no bus
$handler = new RegisterUserHandler($inMemoryUsers);
$handler->handle(new RegisterUser('[email protected]', 'secret'));
$this->assertCount(1, $inMemoryUsers->all());
// Controller/feature test — mock the bus, assert the command was dispatched
$bus = $this->createMock(CommandBusInterface::class);
$bus->expects($this->once())->method('handle')
->with($this->isInstanceOf(RegisterUser::class));
Async upgrade path. The bus abstraction is the seam. When a handler becomes too slow for the request cycle, swap the decorator for one that pushes the command onto a queue (Symfony Messenger, Enqueue, or a simple DB table) instead of calling the inner bus. Handlers are untouched. Do not try to make the bundle itself async — it has no worker, transport, or retry concept.
Integration with Symfony events / domain events. If handlers need to emit domain events after persistence, do it explicitly in the handler (collect events on the aggregate, dispatch after flush) rather than wiring an event dispatcher into the bus. Mixing the two makes dispatch order hard to reason about.
It is effectively unmaintained. Last release 2016, zero stars, zero dependents. Treat it as vendored legacy code, not a dependency you can rely on for security patches or Symfony upgrades. If you are choosing a bus today, Symfony Messenger is the supported answer and gives you the same handler-registry ergonomics plus transports, retries, and middleware out of the box. Adopt this bundle only when it is already in the codebase.
The tag name is not guessable — read the compiler pass. Do not trust blog posts or the README alone; the tag string is whatever the compiler pass passes to findTaggedServiceIds(). A wrong tag fails silently: the handler is registered as a service but never wired into the map, and dispatch throws "no handler found" at runtime rather than at compile time. Confirm with:
bin/console debug:container --tag=<the-exact-tag>
Handler registration is a compile-time concern; clear the cache when it changes. Adding or removing a handler tag requires bin/console cache:clear (or a warmed cache rebuild in prod). A handler that "isn't being called" is almost always a stale container, not a bus bug.
Autowiring will not pick up the tag automatically. If you migrate services to autowiring, you must still add the tag explicitly (_instanceof in services.yml is the cleanest way to apply it to every class implementing the handler interface). Otherwise autowired handlers look fine in the container but are invisible to the bus.
Return values are easy to lose. Some thin command-bus implementations call handle() and discard the return value. If you need the created entity's id back in the controller (very common), check the interface signature first. If it discards, either return via a dedicated result object reference passed into the command, or widen the decorator to propagate the return value — do not reach into the handler from the controller.
Doctrine identity and flush timing. The bundle does not manage the entity manager. If you add a transaction decorator, remember that flush() inside a handler and an outer transaction interact: flush without commit still leaves the entity managed, and a later exception in the same request can leave it dirty for the next dispatch in that request. Flush exactly once, at the boundary.
Debugging dispatch failures. In order of likelihood: (1) stale container / missing cache clear; (2) wrong tag name; (3) command class mismatch — the map is keyed by FQCN, so a command sent through a proxy or an anonymous subclass will miss; (4) handler class not implementing the bundle's handler interface, so the compiler pass skips it. Add a temporary dump($this->map) in the compiler pass and run cache:clear to see the actual map.
Extension points worth knowing. The bus is a normal service, so decoration and aliasing both work. The handler map is built by a compiler pass, so you can post-process it in your own bundle to add aliases or to fail the build on duplicate/ambiguous handlers — worth doing if the team is large, since the duplicate-handler failure mode is otherwise silent. Check whether the bundle exposes a configuration key under tactics_command_bus: for handler paths or default tags before hardcoding anything.
Plan an exit. Because the bundle is a thin map plus a dispatcher, the migration cost is low: write an adapter implementing whatever interface your handlers already use, delegate to Symfony Messenger's MessageBusInterface (commands become messages, handlers become #[AsMessageHandler]), and delete the bundle. Do the decorator work first — logging and transaction middleware survive the migration almost unchanged, which is what makes the swap cheap.
How can I help you explore Laravel packages today?