The Command pattern encapsulates an operation as an object. This sounds abstract until you see the concrete benefits: undo/redo, operation queuing, logging, retry logic – all flow naturally from treating commands as first-class objects. I show the classic GoF implementation, CommandBus, and integration with the Magento 2 message queue.
Classic Command pattern
<?php
declare(strict_types=1);
// Command interface
interface CommandInterface
{
public function execute(): void;
public function undo(): void;
}
// Concrete command
class UpdateProductPriceCommand implements CommandInterface
{
private float $previousPrice;
public function __construct(
private \Magento\Catalog\Api\ProductRepositoryInterface $productRepository,
private int $productId,
private float $newPrice
) {}
public function execute(): void
{
$product = $this->productRepository->getById($this->productId);
$this->previousPrice = (float) $product->getPrice(); // save for undo
$product->setPrice($this->newPrice);
$this->productRepository->save($product);
}
public function undo(): void
{
$product = $this->productRepository->getById($this->productId);
$product->setPrice($this->previousPrice);
$this->productRepository->save($product);
}
}
// Invoker with undo history
class CommandInvoker
{
private array $history = [];
public function execute(CommandInterface $command): void
{
$command->execute();
$this->history[] = $command;
}
public function undo(): void
{
$command = array_pop($this->history);
$command?->undo();
}
public function undoAll(): void
{
while (!empty($this->history)) {
$this->undo();
}
}
}
CommandBus – dispatch and handle
<?php
declare(strict_types=1);
// A command is a plain DTO - no logic, just data
final class CreateOrderCommand
{
public function __construct(
public readonly int $customerId,
public readonly array $items,
public readonly string $shippingMethod,
public readonly ?string $couponCode = null
) {}
}
// Handler contains the logic
class CreateOrderHandler
{
public function __construct(
private \Magento\Sales\Api\OrderRepositoryInterface $orderRepository,
private \Psr\Log\LoggerInterface $logger
) {}
public function handle(CreateOrderCommand $command): int
{
$this->logger->info('Creating order', ['customer_id' => $command->customerId]);
// ... order creation logic
return $orderId;
}
}
// CommandBus routes commands to their handlers
class CommandBus
{
private array $handlers = [];
public function register(string $commandClass, object $handler): void
{
$this->handlers[$commandClass] = $handler;
}
public function dispatch(object $command): mixed
{
$class = get_class($command);
if (!isset($this->handlers[$class])) {
throw new \RuntimeException("No handler registered for: {$class}");
}
return $this->handlers[$class]->handle($command);
}
}
// Wire it up
$bus = new CommandBus();
$bus->register(CreateOrderCommand::class, new CreateOrderHandler($orderRepository, $logger));
// Dispatch
$orderId = $bus->dispatch(new CreateOrderCommand(
customerId: 42,
items: [['sku' => 'MG-001', 'qty' => 2]],
shippingMethod: 'flatrate_flatrate'
));
CommandBus with middleware – logging and transactions
<?php
// Middleware adds cross-cutting concerns without touching handlers
interface MiddlewareInterface
{
public function handle(object $command, callable $next): mixed;
}
class LoggingMiddleware implements MiddlewareInterface
{
public function __construct(private \Psr\Log\LoggerInterface $logger) {}
public function handle(object $command, callable $next): mixed
{
$this->logger->info('Dispatching: ' . get_class($command));
$start = microtime(true);
$result = $next($command);
$time = round((microtime(true) - $start) * 1000, 2);
$this->logger->info('Completed in ' . $time . 'ms');
return $result;
}
}
class TransactionMiddleware implements MiddlewareInterface
{
public function __construct(
private \Magento\Framework\App\ResourceConnection $resource
) {}
public function handle(object $command, callable $next): mixed
{
$this->resource->getConnection()->beginTransaction();
try {
$result = $next($command);
$this->resource->getConnection()->commit();
return $result;
} catch (\Exception $e) {
$this->resource->getConnection()->rollBack();
throw $e;
}
}
}
Integration with Magento 2 message queue
<?php
// Async command - publish to queue instead of executing immediately
class AsyncCommandBus
{
public function __construct(
private CommandBus $syncBus,
private \Magento\Framework\MessageQueue\PublisherInterface $publisher
) {}
public function dispatch(object $command, bool $async = false): mixed
{
if ($async) {
// Serialize and publish to queue
$this->publisher->publish(
'vendor.module.command',
new CommandEnvelope(get_class($command), serialize($command))
);
return null;
}
return $this->syncBus->dispatch($command);
}
}
// Consumer deserializes and executes
class CommandConsumer
{
public function __construct(private CommandBus $bus) {}
public function process(CommandEnvelope $envelope): void
{
$command = unserialize($envelope->getPayload());
$this->bus->dispatch($command);
}
}
Summary
The Command pattern turns operations into objects – this simple change unlocks undo/redo, queueing, logging and retry for free. CommandBus with middleware gives you a clean architecture where handlers focus purely on business logic. In Magento 2 the message queue integration is a natural fit – dispatch heavy commands asynchronously without blocking the HTTP request.
