Command and Chain of Responsibility are two behavioural patterns that both deal with processing requests – but from different angles. Command encapsulates a request as an object, enabling undo, queuing, and logging. Chain of Responsibility passes the request along a handler chain until one processes it. I show GoF implementations and their natural fit in Magento 2.
Command – encapsulate operation as object
<?php
declare(strict_types=1);
interface CommandInterface
{
public function execute(): void;
public function undo(): void;
}
// Command encapsulates an operation and everything needed to undo it
class SetProductPriceCommand implements CommandInterface
{
private float $previousPrice;
public function __construct(
private \Magento\Catalog\Api\ProductRepositoryInterface $repo,
private int $productId,
private float $newPrice
) {}
public function execute(): void
{
$product = $this->repo->getById($this->productId);
$this->previousPrice = (float) $product->getPrice();
$product->setPrice($this->newPrice);
$this->repo->save($product);
}
public function undo(): void
{
$product = $this->repo->getById($this->productId);
$product->setPrice($this->previousPrice);
$this->repo->save($product);
}
}
class ApplyCategoryCommand implements CommandInterface
{
private array $previousCategories = [];
public function __construct(
private \Magento\Catalog\Api\ProductRepositoryInterface $repo,
private int $productId,
private array $categoryIds
) {}
public function execute(): void
{
$product = $this->repo->getById($this->productId);
$this->previousCategories = $product->getCategoryIds();
$product->setCategoryIds($this->categoryIds);
$this->repo->save($product);
}
public function undo(): void
{
$product = $this->repo->getById($this->productId);
$product->setCategoryIds($this->previousCategories);
$this->repo->save($product);
}
}
// Invoker manages the command history
class BulkProductEditor
{
private array $history = [];
public function execute(CommandInterface $command): void
{
$command->execute();
$this->history[] = $command;
}
public function undoLast(): void
{
$command = array_pop($this->history);
$command?->undo();
}
public function undoAll(): void
{
while (!empty($this->history)) {
$this->undoLast();
}
}
}
// Batch edit products with full undo support
$editor = new BulkProductEditor();
$editor->execute(new SetProductPriceCommand($repo, 42, 99.99));
$editor->execute(new ApplyCategoryCommand($repo, 42, [5, 12, 18]));
$editor->execute(new SetProductPriceCommand($repo, 43, 149.99));
$editor->undoLast(); // undo price change on product 43
// $editor->undoAll(); // undo everything
Chain of Responsibility – pass request along handlers
<?php
declare(strict_types=1);
abstract class DiscountHandler
{
private ?DiscountHandler $next = null;
public function setNext(DiscountHandler $handler): DiscountHandler
{
$this->next = $handler;
return $handler;
}
public function handle(array $order): float
{
$discount = $this->calculateDiscount($order);
if ($discount > 0) {
return $discount; // this handler processed it
}
return $this->next?->handle($order) ?? 0.0;
}
abstract protected function calculateDiscount(array $order): float;
}
class CouponDiscountHandler extends DiscountHandler
{
protected function calculateDiscount(array $order): float
{
if (!empty($order['coupon_code'])) {
return $order['total'] * 0.10; // 10% coupon
}
return 0.0;
}
}
class LoyaltyDiscountHandler extends DiscountHandler
{
protected function calculateDiscount(array $order): float
{
if (($order['loyalty_points'] ?? 0) >= 1000) {
return $order['total'] * 0.05; // 5% loyalty
}
return 0.0;
}
}
class BulkOrderDiscountHandler extends DiscountHandler
{
protected function calculateDiscount(array $order): float
{
if (($order['item_count'] ?? 0) >= 10) {
return $order['total'] * 0.08; // 8% bulk
}
return 0.0;
}
}
// Build the chain
$chain = new CouponDiscountHandler();
$chain->setNext(new LoyaltyDiscountHandler())
->setNext(new BulkOrderDiscountHandler());
$discount = $chain->handle([
'total' => 500.0,
'coupon_code' => 'SUMMER10',
'loyalty_points' => 200,
'item_count' => 3,
]);
echo "Discount: {$discount} PLN"; // 50.0 - coupon matched first
Chain with collect-all mode
<?php
// Sometimes you want ALL handlers to contribute, not just the first match
class DiscountPipeline
{
private array $handlers = [];
public function addHandler(DiscountHandler $handler): void
{
$this->handlers[] = $handler;
}
public function calculate(array $order): float
{
$totalDiscount = 0.0;
foreach ($this->handlers as $handler) {
$totalDiscount += $handler->calculateDiscount($order);
}
return min($totalDiscount, $order['total'] * 0.30); // cap at 30%
}
}
Summary
Command is about encapsulating operations as objects – the payoff is undo, queuing, logging, and macro recording. Chain of Responsibility is about routing a request through a sequence of handlers where each one decides whether to act on it. Both patterns reduce the coupling between the “what should happen” decision and the “how it actually happens” implementation. In Magento 2 the discount rule engine uses a very similar chain structure, and the message queue system uses Command objects for asynchronous processing.
