Back to code
PHPSymfonyCQRS

A command bus over a fat controller

A controller that queries, checks business rules, and persists is doing the use case's job. The framework layer should stop at "turn a request into a command" and "turn a result into a response."

Bad - the use case is buried in the controller

final class InvoiceController
{
    public function markAsPaid(string $id, InvoiceRepository $invoices): Response
    {
        $invoice = $invoices->find($id);

        if ($invoice === null) {
            throw new NotFoundHttpException();
        }

        if ($invoice->status() === InvoiceStatus::CANCELLED) {
            throw new ConflictHttpException('Cannot pay a cancelled invoice');
        }

        $invoice->markAsPaid(new DateTimeImmutable());
        $invoices->save($invoice);

        return new JsonResponse(['status' => 'paid']);
    }
}

The invariant - a cancelled invoice can never become paid - lives next to HTTP exception classes. Reuse it from a console command or a queue consumer and you're retyping the rule, with no guarantee it stays in sync.

Good - the controller only translates

final class InvoiceController
{
    public function __construct(private readonly CommandBus $commands) {}

    public function markAsPaid(string $id): Response
    {
        $this->commands->dispatch(new MarkInvoiceAsPaid($id));

        return new JsonResponse(['status' => 'paid']);
    }
}

final class MarkInvoiceAsPaidHandler
{
    public function __construct(private readonly InvoiceRepository $invoices) {}

    public function __invoke(MarkInvoiceAsPaid $command): void
    {
        $invoice = $this->invoices->getById($command->invoiceId);
        $invoice->markAsPaid(new DateTimeImmutable());
        $this->invoices->save($invoice);
    }
}

Invoice::markAsPaid() is where the cancelled-invoice check actually lives

  • inside the aggregate, not the transport layer. The handler is callable from an HTTP controller, a CLI command, or a message consumer with identical guarantees.

The tradeoff: a command class and a handler for what used to be six lines in a controller method. Worth it once more than one entry point needs to trigger the same use case, or once the rule is important enough that "don't forget to re-check it" isn't good enough.