Index

A Command Bus Must Pay for Its Indirection

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::handle

The 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_succeeded

Removing only the CancelOrder mapping produces a different retained trace:

bus_controller
bus_dispatch
outcome_started
transaction_started
transaction_rolled_back
outcome_failed

The 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:CancelOrderHandler
bus_dispatch
outcome_started
transaction_started
handler_selected:ChangeDeliveryAddressHandler

Remove 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
            -> CancelOrderHandler

The 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_failed

The retained row is placed and the retained outcome is failed.

Now keep the same three classes but move outcome recording inward:

TransactionalDispatch
    -> FailAfterHandler
        -> RecordOutcome
            -> CancelOrderHandler

The 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_back

The 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_saved

This 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.