The command bus we removed

A command bus was introduced in 2021 for cross-cutting concerns and grew to eighty-eight commands, every one of which has exactly one handler. What it provides is a transaction and one authorisation check, and what it costs is four frames of indirection between the controller and the work.

The symptom

a stack trace from a production exception:

  #0  OrderRepository::save()
  #1  PlaceOrderHandler::__invoke()
  #2  HandlerLocator::resolve()
  #3  TransactionMiddleware::handle()
  #4  AuthorisationMiddleware::handle()
  #5  LoggingMiddleware::handle()
  #6  CommandBus::dispatch()
  #7  OrderController::store()

six frames between the controller and the repository,
of which one does work.

Reading a stack trace means skipping the middle, and skipping the middle is a habit that eventually skips something that matters. The dispatch also means a jump-to-definition from the controller lands on a command class rather than on the code that runs.

Why it happens

A bus is right for cross-cutting concerns and gets adopted for indirection, because the indirection is the visible feature. Eighty-eight one-to-one mappings is a naming convention with a dispatcher attached.

The fix

What the bus was actually providing

five middleware, examined:

  transaction     used. wraps every dispatch in a
                  database transaction.
  authorisation   used once, on one command.
  logging         registered, configured to log at
                  debug, into a channel discarding
                  debug. no output since 2022.
  retry           registered. every command it could
                  retry is dispatched synchronously
                  from an HTTP request. it has never
                  retried anything.
  metrics         emits a counter nobody has a
                  dashboard for.

two of five do something, and one of those does it
once.

Where the transaction belongs instead

// the bus wrapped EVERY dispatch in a transaction,
// including the 41 commands that only read.

// and it wrapped them at the wrong granularity: a
// controller dispatching two commands got two
// transactions, when the operation needed one.

public function place(Basket $basket, Customer $customer): Order
{
    return DB::transaction(function () use ($basket, $customer) {
        $order = $this->orders->create($basket, $customer);
        $this->stock->reserve($order->lines());
        $this->outbox->record($order, 'order.placed');

        return $order;
    });
}

A transaction per dispatch is a transaction at the granularity of the framework rather than of the operation, which was producing two transactions where one was needed in four places. Moving it into the service made the boundary explicit and removed it from the forty-one commands that only read.

And the authorisation check

// the bus checked a policy named after the command
// class, for every command, and found one.

// in the controller, which is where the request is
public function refund(RefundRequest $request, Order $order): JsonResponse
{
    $this->authorize('refund', $order);

    $reference = $this->refunds->issue($order, $request->amount());

    return response()->json(['reference' => $reference], 201);
}

Authorisation belongs where the actor is, and the actor is in the request. Doing it in the bus meant the check ran for console commands and queued jobs, where there is no user — which was handled by a null check that permitted everything, and is the same shape as the entitlements middleware in the deleted module.

The mechanical removal

$ vendor/bin/rector process src --config=rector-remove-bus.php --dry-run
  188 files would be changed

# the rule: a dispatch of a command with exactly one
# handler becomes a direct call to the handler's
# service, with the command's constructor arguments
# passed through.
#
# 81 of 88 converted mechanically.
# 7 needed a decision, all of them commands dispatched
# from more than one place with different transaction
# expectations.

Eighty-one of eighty-eight mechanically is a good rate for a refactor of this shape, and the seven that needed a decision are the ones where the bus had been hiding an inconsistency. Two of them were dispatching the same command inside and outside a transaction, which had been working by accident.

What we kept

// the queued half is a genuinely different thing and
// stays
RebuildSearchIndex::dispatch($since);

// a queued job IS a message: it is serialised, it
// crosses a process boundary, it has a retry policy
// and a failure record.
//
// a synchronous command with one handler is a method
// call with a class around it.

The distinction is whether the message crosses a boundary. A queued job is serialised into a broker and executed by another process, which is what a command object is for; a synchronous dispatch to a handler in the same request is a method call that has been given a name and a locator.

The debugging difference

the same production exception, after:

  #0  OrderRepository::save()
  #1  OrderService::place()
  #2  OrderController::store()

three frames, all of which are ours and all of which
do something.

and the thing that is harder to measure: jump-to-
definition from the controller now lands on the code
that runs, rather than on a data class whose handler
has to be found by name.

Verifying it worked

$ git diff --stat main | tail -1
 214 files changed, 402 insertions(+), 1,880 deletions(-)

$ vendor/bin/phpunit
  Tests: 1,604 passed

$ ./bin/response-times --sample=2000 --compare=2026-06
  p50  38ms → 36ms
  p95 178ms → 171ms

$ ./bin/transaction-count --route=orders.store
  1        # was 2

$ vendor/bin/deptrac
  0 violations

Seven milliseconds off the p95 is the dispatch overhead and is not the reason to do this. The transaction count on the order creation route going from two to one is the correctness improvement, and it had been two since 2021.

What this costs

A decision reversed after five years, and the pattern is not wrong — a command bus with genuine cross-cutting concerns and several handlers per message is a good design that this application did not have. Removing it is a statement about this codebase rather than about the pattern.

It also removes the place where a future cross-cutting concern would go. The next time something needs to happen around every write, it will be added to each service or it will be a bus again — and the honest position is that the second is possible and this deletion makes it a decision rather than an inheritance.