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.