Wzorzec Command zamienia żądanie wykonania operacji w samodzielny obiekt. To pozwala kolejkować operacje, logować je, cofać (undo), a nawet budować makra z sekwencji komend. W ekosystemie PHP pattern Command znajdziesz w Symfony Console, w Magento jako podstawę systemu kolejkowania i w każdym systemie który obsługuje historię operacji.
Idea wzorca
Bez wzorca Command logika wywołania operacji jest bezpośrednio w wywołującym. Trudno to kolejkować, logować czy cofać:
<?php
// Bez Command - wywołujący wie za dużo
class OrderController
{
public function cancel(int $orderId): void
{
$order = $this->orderRepository->getById($orderId);
$order->setStatus('cancelled');
$this->orderRepository->save($order);
$this->emailService->sendCancellationEmail($order);
$this->inventoryService->restoreStock($order);
$this->logger->info('Order cancelled', ['id' => $orderId]);
// ...i jeszcze 5 innych kroków
}
}
Command przenosi całą tę logikę do obiektu komendy:
Implementacja wzorca Command
<?php
declare(strict_types=1);
// Interfejs komendy - jedna metoda execute()
interface CommandInterface
{
public function execute(): void;
}
// Interfejs odwracalnej komendy
interface ReversibleCommandInterface extends CommandInterface
{
public function undo(): void;
}
<?php
declare(strict_types=1);
// Konkretna komenda - enkapsuluje całą operację anulowania zamówienia
class CancelOrderCommand implements ReversibleCommandInterface
{
private string $previousStatus = '';
public function __construct(
private int $orderId,
private OrderRepositoryInterface $orderRepository,
private EmailServiceInterface $emailService,
private InventoryServiceInterface $inventoryService,
private \Psr\Log\LoggerInterface $logger
) {}
public function execute(): void
{
$order = $this->orderRepository->getById($this->orderId);
// Zapamiętaj poprzedni stan dla undo()
$this->previousStatus = $order->getStatus();
$order->setStatus('cancelled');
$this->orderRepository->save($order);
$this->inventoryService->restoreStock($order);
$this->emailService->sendCancellationEmail($order);
$this->logger->info('Order cancelled', ['order_id' => $this->orderId]);
}
public function undo(): void
{
if (empty($this->previousStatus)) {
throw new \LogicException('Cannot undo - command was not executed');
}
$order = $this->orderRepository->getById($this->orderId);
$order->setStatus($this->previousStatus);
$this->orderRepository->save($order);
$this->logger->info('Order cancellation undone', ['order_id' => $this->orderId]);
}
}
Command Invoker – historia i undo
<?php
declare(strict_types=1);
// Invoker - zarządza wykonywaniem i historią komend
class CommandBus
{
/** @var ReversibleCommandInterface[] */
private array $history = [];
public function execute(CommandInterface $command): void
{
$command->execute();
if ($command instanceof ReversibleCommandInterface) {
$this->history[] = $command;
}
}
public function undo(): void
{
if (empty($this->history)) {
throw new \UnderflowException('No commands to undo');
}
$command = array_pop($this->history);
$command->undo();
}
public function undoAll(): void
{
while (!empty($this->history)) {
$this->undo();
}
}
}
// Użycie
$bus = new CommandBus();
$bus->execute(new CancelOrderCommand(42, $orderRepo, $emailService, $inventoryService, $logger));
$bus->execute(new CancelOrderCommand(43, $orderRepo, $emailService, $inventoryService, $logger));
// Cofnij ostatnią operację
$bus->undo(); // przywraca zamówienie 43
// Cofnij wszystko
$bus->undoAll();
Command z kolejkowaniem
Command naturalnie integruje się z systemami kolejkowania – obiekt komendy serializujesz i wysyłasz do queue, consumer deserializuje i wywołuje execute():
<?php
declare(strict_types=1);
// Komenda do kolejkowania - musi być serializowalna (tylko dane, nie serwisy)
class SendOrderConfirmationCommand implements CommandInterface
{
public function __construct(
public readonly int $orderId,
public readonly string $customerEmail,
public readonly string $locale
) {}
public function execute(): void
{
// W kontekście konsumera zależności wstrzykuje DI
// Tu tylko logika bez serwisów - serwisy wstrzykuje consumer
}
}
// Consumer kolejki - wstrzykuje zależności i wykonuje komendę
class OrderConfirmationConsumer
{
public function __construct(
private EmailServiceInterface $emailService,
private TemplateRenderer $renderer
) {}
public function process(SendOrderConfirmationCommand $command): void
{
$template = $this->renderer->render(
'order_confirmation',
['order_id' => $command->orderId, 'locale' => $command->locale]
);
$this->emailService->send($command->customerEmail, $template);
}
}
Makra – sekwencje komend
<?php
// Makro - komenda złożona z wielu komend
class BatchCancelOrdersCommand implements CommandInterface
{
/** @var CommandInterface[] */
private array $commands = [];
public function addCommand(CommandInterface $command): self
{
$this->commands[] = $command;
return $this;
}
public function execute(): void
{
foreach ($this->commands as $command) {
$command->execute();
}
}
}
// Użycie - anuluj wiele zamówień jako jedną operację
$batch = new BatchCancelOrdersCommand();
foreach ([42, 43, 44] as $orderId) {
$batch->addCommand(
new CancelOrderCommand($orderId, $orderRepo, $emailService, $inventoryService, $logger)
);
}
$bus->execute($batch);
Command w Magento 2
Magento 2 używa wzorca Command w kilku miejscach. System komend CLI (bin/magento) to klasyczny Command z Symfony Console. Bardziej interesujący przykład to moduł Magento_SalesRule, gdzie każda reguła cenowa implementuje własną logikę obliczania rabatu jako oddzielna klasa – to wariant Command gdzie każdy „handler” enkapsuluje jedną regułę.
Możesz też zbudować własny Command Bus w Magento integrując się z systemem kolejkowania z poprzednich wpisów:
<?php
namespace Vendor\Module\CommandBus;
use Magento\Framework\MessageQueue\PublisherInterface;
class AsyncCommandBus
{
private const TOPIC = 'vendor.module.command';
public function __construct(
private PublisherInterface $publisher
) {}
// Wyślij komendę do asynchronicznego wykonania przez consumer
public function dispatch(object $command): void
{
$this->publisher->publish(self::TOPIC, $command);
}
}
Podsumowanie
Wzorzec Command błyszczy gdy operacje muszą być kolejkowane, logowane lub odwracane. Przeniesienie logiki z kontrolera do obiektu komendy upraszcza testy (testujesz komendę w izolacji), umożliwia ponowne użycie (ta sama komenda z controllera i z CLI) i otwiera drzwi do zaawansowanych funkcji jak undo czy makra. W kontekście Magento 2 warto myśleć o Command gdy implementujesz operacje biznesowe które lądują w kolejce – Message Queue i Command to naturalna para.
