PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Command and Chain of Responsibility in PHP – behavioural patterns

by Henryk Tews / Tuesday, 14 June 2022 / Published in Wzorce projektowe

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.

About Henryk Tews

What you can read next

Flyweight pattern – object sharing, instance cache, Magento 2
GoF patterns in Magento 2 – where to find them and how they work
Observer pattern in PHP and the Magento 2 event system

© 2026 Created by

TOP
Zarządzaj zgodą
Aby zapewnić jak najlepsze wrażenia, korzystamy z technologii, takich jak pliki cookie, do przechowywania i/lub uzyskiwania dostępu do informacji o urządzeniu. Zgoda na te technologie pozwoli nam przetwarzać dane, takie jak zachowanie podczas przeglądania lub unikalne identyfikatory na tej stronie. Brak wyrażenia zgody lub wycofanie zgody może niekorzystnie wpłynąć na niektóre cechy i funkcje.
Funkcjonalne Always active
Przechowywanie lub dostęp do danych technicznych jest ściśle konieczny do uzasadnionego celu umożliwienia korzystania z konkretnej usługi wyraźnie żądanej przez subskrybenta lub użytkownika, lub wyłącznie w celu przeprowadzenia transmisji komunikatu przez sieć łączności elektronicznej.
Preferencje
Przechowywanie lub dostęp techniczny jest niezbędny do uzasadnionego celu przechowywania preferencji, o które nie prosi subskrybent lub użytkownik.
Statystyka
Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do celów statystycznych. Przechowywanie techniczne lub dostęp, który jest używany wyłącznie do anonimowych celów statystycznych. Bez wezwania do sądu, dobrowolnego podporządkowania się dostawcy usług internetowych lub dodatkowych zapisów od strony trzeciej, informacje przechowywane lub pobierane wyłącznie w tym celu zwykle nie mogą być wykorzystywane do identyfikacji użytkownika.
Marketing
Przechowywanie lub dostęp techniczny jest wymagany do tworzenia profili użytkowników w celu wysyłania reklam lub śledzenia użytkownika na stronie internetowej lub na kilku stronach internetowych w podobnych celach marketingowych.
  • Manage options
  • Manage services
  • Manage {vendor_count} vendors
  • Read more about these purposes
Zobacz preferencje
  • {title}
  • {title}
  • {title}