micro-module/tactician-bundle
MicroModule TacticianBundle integrates the Tactician command bus into Symfony applications, helping you configure commands, handlers, and middleware with less boilerplate and structure command-driven business logic cleanly.
⚠️ Reality check first:
micro-module/tactician-bundlehas 0 stars and 0 dependents. It is not the official integration — that'sleague/tactician-bundle. Before adopting it, diff its config tree and middleware wiring againstleague/tactician-bundle; if they match, prefer the maintained package. The guidance below assumes the API mirrors the standard Tactician/Symfony conventions (command bus servicetactician.commandbus, handler tagtactician.handler, middleware tagtactician.middleware).
Install
composer require micro-module/tactician-bundle
For Symfony Flex projects the recipe (if any) registers the bundle automatically. If not, add it to config/bundles.php:
return [
// ...
MicroModule\TacticianBundle\TacticianBundle::class => ['all' => true],
];
First place to look: the generated/example config at config/packages/tactician.yaml. Confirm the default command bus exists:
tactician:
commandbus:
default:
middleware:
- tactician.middleware.locking # optional
- tactician.middleware.command_handler
Verify boot
bin/console debug:container tactician.commandbus
bin/console debug:container --tag=tactician.handler
If the second command lists nothing, your handlers aren't being tagged yet (see Gotchas).
First use case — a classic write operation:
final class RegisterUser
{
public function __construct(
public readonly string $email,
public readonly string $plainPassword,
) {}
}
final class RegisterUserHandler
{
public function __construct(private UserRepository $users, private PasswordHasher $hasher) {}
public function __invoke(RegisterUser $command): void
{
$this->users->save(new User($command->email, $this->hasher->hash($command->plainPassword)));
}
}
Register the handler with the tag (or rely on autoconfiguration if the bundle provides it):
App\Application\User\RegisterUserHandler:
tags: [{ name: tactician.handler, command: App\Application\User\RegisterUser }]
Dispatch from a controller — inject CommandBus (the interface, not the concrete class):
public function __invoke(Request $request, CommandBus $bus): Response
{
$bus->handle(new RegisterUser($request->get('email'), $request->get('password')));
return new Response('', 201);
}
Tag by command, not by interface. Each handler is a plain callable/invokable service. The command attribute on the tag tells the bus which command class it handles. This is the single most important wiring detail — miss it and you get a runtime No handler for command error.
Use one bus per bounded context, not per command. Multiple named buses (default, sync, async) let you attach different middleware stacks — e.g. a logger/transaction middleware on the synchronous bus, a queued transport on the async one. Inject the specific bus via a named alias if your app grows.
Middleware is the extension surface. Every cross-cutting concern — transactions, logging, validation, authorization — belongs in a middleware, not in handlers:
final class TransactionMiddleware implements Middleware
{
public function __construct(private EntityManagerInterface $em) {}
public function execute(object $command, callable $next): object
{
$this->em->beginTransaction();
try {
$result = $next($command);
$this->em->flush();
$this->em->commit();
return $result;
} catch (\Throwable $e) {
$this->em->rollback();
throw $e;
}
}
}
Tag it tactician.middleware, then list it before tactician.middleware.command_handler in the config. Order is the pipeline order.
Combine with Symfony's Validator via a ValidationMiddleware that validates the command object. Commands are DTOs, so Symfony constraints (@Assert\Email, etc.) work directly on them — this keeps validation declarative and out of the handler.
Dispatch from controllers or from other services, never both deeply nested in one request. Nesting command buses is possible ($bus->handle() from inside a handler) but it breaks transaction and middleware assumptions. Prefer a single top-level dispatch and let the handler orchestrate its own collaborators.
Test at two levels: unit-test handlers directly (they're plain classes — no container needed), and integration-test the bus wiring with KernelTestCase asserting debug:container shows the handler tagged. For the latter, a smoke test that dispatches one command and asserts side effects catches most misconfiguration.
Return values: Tactician's handle() returns whatever the final middleware returns — usually the handler's return value or null. If you need a result (e.g. an ID), have the handler return it; otherwise treat commands as fire-and-forget. Do not assume a value is present.
composer require, you'll see No handler for command [X]. Diagnose with bin/console debug:container --tag=tactician.handler and wire explicitly, or add an autoconfigure rule in services.yaml for your handler namespace.command attribute must be the FQCN, not the short name or the handler class name. Copy-paste errors here are the #1 cause of runtime failures.command_handler middleware placed before a transaction middleware means transactions wrap nothing. Read the config array top-to-bottom as execution order.tactician.yaml, run bin/console cache:clear in dev — a stale container will keep old middleware even after a code change.get_class($command). In league/tactician-bundle there's tactician.middleware.logger; confirm whether this fork ships an equivalent — if not, write the 10-line one yourself.composer.json in the repo that the constraint on league/tactician and the Symfony framework-bundle range matches your app (e.g. ^6.0 || ^7.0). If it pins an old Symfony major, you'll hit a resolver conflict at install and should switch to the official package.CommandBus interface (League\Tactician\CommandBus) type-hinted in your code, not the concrete bus class — that makes swapping this bundle for league/tactician-bundle later a config-only change.DependencyInjection/ folder for a ...CompilerPass and whether it's registered in the bundle build() — if it exists, you can skip hand-tagging every handler.How can I help you explore Laravel packages today?