Four Laravel routes handle two commands in the reproducible fixture. Two routes inject typed handlers. Two inject Laravel’s dispatcher. All four return 204, and both call graphs leave the same SQLite rows behind.
Then one line disappears from the dispatcher’s handler map. The bus route
still resolves its controller, constructs CancelOrder, and enters the
pipeline. It returns 500 without selecting a handler. The direct controller
has no equivalent registry entry to lose because its constructor already
names DirectCancelOrderHandler.
A command bus does not remove the dependency between a command and its handler. It moves that dependency into a runtime map. That trade can be worth making, but only when the bus buys uniform dispatch behavior which matters more than the control flow it hides.
Equal responses conceal different call graphs
The direct cancellation route declares the next edge in its constructor:
final readonly class DirectCancelOrderController
{
public function __construct(
private DirectCancelOrderHandler $handler,
) {}
public function __invoke(Request $request, string $order): JsonResponse
{
$this->handler->handle(new CancelOrder(
OrderId::fromString($order),
(string) $request->input('reason'),
));
return response()->json(status: 204);
}
}The bus route declares a dispatcher instead:
final readonly class BusCancelOrderController
{
public function __construct(private Dispatcher $dispatcher) {}
public function __invoke(Request $request, string $order): JsonResponse
{
$this->dispatcher->dispatchNow(new CancelOrder(
OrderId::fromString($order),
(string) $request->input('reason'),
));
return response()->json(status: 204);
}
}dispatchNow() is the relevant method here. Laravel’s dispatcher
contract defines it as handling a command in the
current process. Nothing in this comparison is queued.
The controller has become stable with respect to handler type, but the application still needs to answer the missing question elsewhere:
$dispatcher->map([
CancelOrder::class => CancelOrderHandler::class,
ChangeDeliveryAddress::class => ChangeDeliveryAddressHandler::class,
]);Laravel’s dispatcher source looks for that mapped
handler during dispatchNow(), then sends the command and handler through the
configured pipeline. The resulting graph crosses the controller and service
provider:
BusCancelOrderController
-> Dispatcher::dispatchNow(CancelOrder)
-> provider map: CancelOrder => CancelOrderHandler
-> CancelOrderHandler::handleThe clean run generated both direct controller-to-handler edges, both bus mappings, and both dispatcher call sites from committed source. This makes a bounded point: the bus graph is displaced, not dissolved. It does not prove that developers will find one graph faster than the other.
A missing map fails after dispatch has begun
With the complete map, cancellation crosses this bus trace:
bus_controller
bus_dispatch
outcome_started
transaction_started
handler_selected:CancelOrderHandler
domain_invoked:cancel
repository_saved
transaction_committed
outcome_succeededRemoving only the CancelOrder mapping produces a different retained trace:
bus_controller
bus_dispatch
outcome_started
transaction_started
transaction_rolled_back
outcome_failedThe row remains placed. No handler is selected and no domain method runs. The failure occurs after the controller has handed control to the dispatcher and after the outer pipes have started.
That timing is not automatically bad. Runtime registries are a normal part of framework applications. It does mean the registry is application behavior, not incidental configuration. If a command bus is part of the architecture, an incomplete map deserves the same attention as a missing container binding: generate an inventory, resolve it in a smoke test, or reject omissions with an architecture check.
A Composition Root Makes Choices Visible makes the same argument about adapter selection. The command map and pipe list belong to that visible runtime composition.
Shared dispatch policy is what the bus can buy
The fixture gives every command two mandatory policies: run the handler in a database transaction and record whether dispatch started, succeeded, or failed. Cancellation and delivery-address changes must cross both policies, regardless of whether HTTP, a console command, or another application service starts the work.
Laravel can install that stack once:
$dispatcher->pipeThrough([
RecordOutcome::class,
TransactionalDispatch::class,
FailAfterHandler::class,
]);Symfony’s Messenger documentation describes the same middleware model: ordered layers can add concerns such as logging, validation, and transactions around message handling. The Laravel fixture verifies the mechanism rather than assuming that middleware is valuable merely because a framework offers it.
Both commands produce the same pipe prefix before selecting different handlers:
bus_dispatch
outcome_started
transaction_started
handler_selected:CancelOrderHandlerbus_dispatch
outcome_started
transaction_started
handler_selected:ChangeDeliveryAddressHandlerRemove TransactionalDispatch, force the repository to throw after updating
the row, and the command still fails—but the cancelled row remains. Remove
RecordOutcome, and cancellation still succeeds with a 204 response while
the required outcome list is empty. Each mutation breaks a different policy.
That is the payment test. The bus is doing more than translating one class name into another. It supplies one enforceable entry path for policies shared by independently meaningful commands.
There is an important condition hidden inside “enforceable”: every relevant caller must use the dispatcher. A controller which calls a handler directly walks around the pipes. The fixture’s source analyzer rejects that bypass, as well as a handler which starts nested dispatch and a supposedly cross-cutting pipe which imports one concrete command type. Central policy helps only when the boundary is owned.
Pipe order changes the meaning of success
The pipe list is not a bag of decorators. Before the handler, control moves from the first pipe toward the last. After the handler, it unwinds in the opposite direction.
The controlled failure pipe calls the next layer and then throws. With outcome recording outside the transaction and failure pipe, the stack is:
RecordOutcome
-> TransactionalDispatch
-> FailAfterHandler
-> CancelOrderHandlerThe handler saves the order. FailAfterHandler throws. The transaction rolls
the row back, and the outer outcome pipe observes the exception:
repository_saved
failure_after_handler
transaction_rolled_back
outcome_failedThe retained row is placed and the retained outcome is failed.
Now keep the same three classes but move outcome recording inward:
TransactionalDispatch
-> FailAfterHandler
-> RecordOutcome
-> CancelOrderHandlerThe inner outcome pipe sees its next layer return and records success. Only then does the failure pipe throw and the outer transaction roll back:
repository_saved
outcome_succeeded
failure_after_handler
transaction_rolled_backThe row is placed, yet the outcome says succeeded. The wrong-order test fails on that contradiction.
This in-memory recorder is deliberately small. A durable audit record needs its own transaction, delivery, and recovery design. The example establishes only that middleware order changes observable semantics. It does not propose an in-memory list as production observability.
Typed calls can share policy without a bus
The direct comparison is not two handlers with copied transaction blocks. A
typed handler delegates its wrapper behavior to DirectDispatch:
final readonly class DirectCancelOrderHandler
{
public function handle(CancelOrder $command): void
{
$this->dispatch->run($command, function () use ($command): void {
$order = $this->orders->get($command->orderId);
$order->cancel($command->reason);
$this->orders->save($order);
});
}
}The address handler uses the same wrapper. The direct path therefore records the same outcomes and proves the same rollback behavior while keeping a typed controller-to-handler edge.
Bypass that wrapper in only the cancellation handler and force a repository failure. The trace loses the transaction and outcome events, and the row remains cancelled:
direct_controller
direct_handler:DirectCancelOrderHandler
domain_invoked:cancel
repository_savedThis is the direct design’s corresponding risk. A shared collaborator or typed decorator can centralize policy, but each handler graph must include it. The bus centralizes the ordered stack at one dispatch boundary; the direct design keeps each application edge explicit. Neither makes omission impossible without a check.
A typed decorator is preferable when commands need different policies or when
constructor-visible composition is the stronger constraint. A small
TransactionRunner is enough when the transaction is the only shared concern.
HTTP middleware is appropriate when the policy belongs to HTTP and does not
need to cover console, queue, scheduler, or internal callers.
Reverse toward a bus when several command types must cross the same ordered policies and one governed dispatch boundary is cheaper than maintaining the typed wrapper graph. Reverse away when most policies vary by command, the map adds a second place to discover every handler, or callers cannot be kept on the dispatch path. There is no useful command-count threshold: the deciding variables are policy uniformity, call-graph visibility, and enforcement.
Synchronous dispatch does not authorize a queue
Laravel’s dispatcher contract includes immediate
and queued forms of dispatch, which can make dispatchNow() look like a
convenient first step toward queued work. The command object may survive that
move. Its execution contract does not.
A synchronous caller receives failure in the current call. Laravel’s queue documentation instead covers background workers, attempts, timeouts, failed jobs, retries, and monitoring. Serialization, idempotency, ordering, changed state before handling, and operator recovery therefore enter the design.
The fixture has one PHP process, one SQLite database, and no queue. It cannot support an inference about asynchronous correctness or throughput. That boundary belongs to the next article rather than being smuggled in as a benefit of the synchronous bus.
Make the bus submit an expense report
Start with the typed call. It states who handles the command, fails through the ordinary object graph, and needs no registry. Add a bus only after naming the ordered behavior every dispatch must share. Keep the command map, pipe order, dispatcher call sites, and bypass rules inspectable. Mutate each mandatory piece and retain a test which fails for the reason that piece exists.
When the only line between caller and handler is
dispatchNow($command), the bus has hidden a method call and sent no receipt.
When several commands demonstrably need the same transaction, outcome,
authorization, or telemetry boundary, an enforced pipeline can pay for that
indirection. The bus earns its place through the policy it guarantees, not the
vocabulary it introduces.