league/tactician-command-events
Adds command events to League Tactician, dispatching events around command handling so you can hook into the command lifecycle with listeners instead of cluttering handlers. A lightweight integration for PHP apps using Tactician.
league/tactician-command-events is a Tactician middleware that lets a command record domain events while it is being handled, then dispatches those events through an event emitter after the handler returns. It's the glue between your command bus and your listener layer, so handlers stay focused on persistence and side effects move into listeners.
Install and wire it up (three steps):
composer require league/tactician-command-events
use League\Event\Emitter;
use League\Tactician\CommandBus;
use League\Tactician\CommandEvents\EventMiddleware;
use League\Tactician\Handler\CommandHandlerMiddleware;
// ... your locator/method-name extractor
$emitter = new Emitter();
$commandBus = new CommandBus([
new EventMiddleware($emitter), // outermost
$transactionMiddleware, // optional, see ordering note below
new CommandHandlerMiddleware($locator, $extractor, $inflector),
]);
The command-side contract is small: your command object carries the recorded events (typically a trait giving you raise($event) / releaseEvents()), and the middleware pulls them off after the handler succeeds.
Where to look first: the middleware class itself is only a few dozen lines — read it before anything else, because it defines the whole semantics (when events are collected, when they're cleared, what happens on exceptions). Then read the README example, which is effectively the only documentation this package has.
First use case: a RegisterUser command that persists a user and raises UserRegistered. The handler does the insert; a SendWelcomeEmail listener subscribed on the emitter reacts. You get a handler that's four lines long and side effects you can test, disable, or make async independently.
Record in the handler, dispatch after commit. The natural flow:
class RegisterUserHandler
{
public function handle(RegisterUser $command)
{
$user = User::create($command->email());
$user->save();
$command->raise(new UserRegistered($user->id));
}
}
Listeners receive fully-formed value objects and never see the command bus.
Keep the middleware outside your transaction middleware. Tactician's stack unwinds in reverse, so the middleware listed first runs its post-$next code last:
new CommandBus([
new EventMiddleware($emitter), // dispatches AFTER commit ✅
new TransactionMiddleware($em), // commits when handler returns
new CommandHandlerMiddleware(...),
]);
Swap them and listeners fire while the transaction is still open — listeners that read the DB (projections, lookups) will see stale or missing rows.
Group listeners by name, not by class. Since the emitter is league/event, use string event names and wildcards (user.*) so a single listener can be registered for a family of events without a class-per-listener. Pass the emitter through your container so listeners can be wired in one service provider.
Use it for post-command fan-out, not for domain modelling. Good fits: sending mail/notifications, writing an audit row, pushing a job to a queue, invalidating cache, updating a read model synchronously. Poor fits: anything the command's correctness depends on (see gotchas).
Testing. Swap in a real Emitter and attach a collector listener, then assert after $bus->handle($command):
$emitter->addListener('user.registered', function ($event) use (&$seen) {
$seen[] = $event;
});
Because the events are on the command until released, you can also assert on $command->releaseEvents() in a unit test without any bus at all — often the fastest feedback loop.
Compose with nested buses deliberately. If a handler dispatches a second command on the same bus, that inner command's events fire from the inner middleware pass, before the outer handler returns. If ordering matters (welcome email before the invoice job), don't rely on nesting — raise both events from the outer command.
Events are dispatched in-memory, after the fact, with no delivery guarantee. This is the single biggest trap. Handler commits → events dispatch → a listener throws → the user exists but the email never sent. There is no outbox, no retry, no persistence. If a side effect must happen exactly once, write the event to an outbox table inside the transaction and have a worker drain it; treat this middleware as best-effort only.
Listener exceptions propagate to the caller. The command has already been handled, so the caller sees a failure for work that actually succeeded — and may retry, producing a duplicate. Wrap fan-out listeners in try/catch + logging if that's unacceptable, or move them to a queue.
Releasing clears the events; reusing a command instance re-dispatches nothing (or double-dispatches, depending on your recorder). Treat commands as single-use value objects. If you re-run a command in a retry loop, don't assume the events survive.
Middleware ordering is the #1 debugging issue. Symptom: "listener can't find the record the handler just created." Fix: move EventMiddleware up the list. Symptom: "listener runs twice." Fix: the middleware is registered on both a decorated bus and the inner bus.
Version reality check before you commit. Last release was 2015-12-31, zero dependents, and it predates PSR-14 — it uses league/event (v2 era) rather than Psr\EventDispatcher\EventDispatcherInterface. Check its composer.json constraints against your PHP/Tactician version; on a modern stack expect to either pin league/event or, more realistically, vendor the middleware into your own codebase. It is roughly this:
class EventMiddleware
{
public function __construct(private EmitterInterface $emitter) {}
public function execute($command, callable $next)
{
$result = $next($command);
foreach ($command->releaseEvents() as $event) {
$this->emitter->emit($event);
}
return $result;
}
}
Owning thirty lines beats inheriting a 2015 dependency — and lets you swap league/event for PSR-14 in an afternoon.
Return values pass through untouched. The middleware must return $next($command)'s result; if you fork it, don't accidentally return void and break handlers that return identifiers.
Name collision. EventMiddleware is a very likely name in your own codebase already; alias the import on day one.
Don't reach for this if you only have one or two side effects. A couple of method calls at the end of the handler are clearer than a bus, an emitter, a listener registration, and an event class. The payoff starts when the number of reactions to a command grows, or when different deployments (web, worker, CLI) need different subsets of them.
How can I help you explore Laravel packages today?