PHP / Magento Dev Blog

  • Publikacje
  • O autorze
  • Kontakt

Chain of Responsibility – validator chain, di.xml configuration with sortOrder

by Henryk Tews / Tuesday, 08 February 2022 / Published in Wzorce projektowe

Chain of Responsibility passes a request along a chain of handlers. Each handler decides whether to process it or pass it on. The pattern is perfect for multi-step validation, middleware pipelines, and request processing where the set of handlers needs to change without modifying callers. I build a validator chain in PHP and show how to configure handler order through Magento 2 di.xml.

Classic Chain of Responsibility

<?php

declare(strict_types=1);

abstract class OrderValidator
{
    private ?OrderValidator $next = null;

    public function setNext(OrderValidator $validator): static
    {
        $this->next = $validator;
        return $validator; // allows chaining: $a->setNext($b)->setNext($c)
    }

    // Template method - subclasses implement validate(), not handle()
    public function handle(array $order): array
    {
        $errors = $this->validate($order);

        if (!empty($errors)) {
            return $errors; // stop chain on first failure
        }

        if ($this->next !== null) {
            return $this->next->handle($order);
        }

        return []; // all validators passed
    }

    abstract protected function validate(array $order): array;
}

class MinimumOrderValueValidator extends OrderValidator
{
    public function __construct(private float $minimum = 10.0) {}

    protected function validate(array $order): array
    {
        if (($order['total'] ?? 0) < $this->minimum) {
            return ["Minimum order value is {$this->minimum} PLN"];
        }
        return [];
    }
}

class StockAvailabilityValidator extends OrderValidator
{
    protected function validate(array $order): array
    {
        $errors = [];
        foreach ($order['items'] ?? [] as $item) {
            if (($item['qty_available'] ?? 0) < ($item['qty_ordered'] ?? 1)) {
                $errors[] = "Insufficient stock for SKU: {$item['sku']}";
            }
        }
        return $errors;
    }
}

class CustomerCreditValidator extends OrderValidator
{
    protected function validate(array $order): array
    {
        $creditLimit = $order['customer']['credit_limit'] ?? PHP_FLOAT_MAX;
        if ($order['total'] > $creditLimit) {
            return ['Order exceeds customer credit limit'];
        }
        return [];
    }
}

// Build and run the chain
$chain = new MinimumOrderValueValidator(20.0);
$chain->setNext(new StockAvailabilityValidator())
      ->setNext(new CustomerCreditValidator());

$errors = $chain->handle([
    'total'    => 150.0,
    'items'    => [['sku' => 'MG-001', 'qty_ordered' => 2, 'qty_available' => 5]],
    'customer' => ['credit_limit' => 1000.0],
]);

echo empty($errors) ? 'Order valid' : implode(', ', $errors);

Chain with sortOrder via Magento 2 di.xml

<?php

declare(strict_types=1);

// Interface for all validators
interface OrderValidatorInterface
{
    public function validate(array $order): array;
}

// Chain builder - accepts sorted list of validators from DI
class OrderValidationChain
{
    /** @var OrderValidatorInterface[] */
    private array $validators;

    public function __construct(array $validators = [])
    {
        // Sort by sortOrder key if provided
        usort($validators, fn($a, $b) =>
            ($a['sortOrder'] ?? 100) <=> ($b['sortOrder'] ?? 100)
        );
        $this->validators = array_column($validators, 'validator');
    }

    public function validate(array $order): array
    {
        foreach ($this->validators as $validator) {
            $errors = $validator->validate($order);
            if (!empty($errors)) {
                return $errors; // stop on first failure
            }
        }
        return [];
    }

    // Or collect ALL errors from all validators
    public function validateAll(array $order): array
    {
        $allErrors = [];
        foreach ($this->validators as $validator) {
            $errors = $validator->validate($order);
            array_push($allErrors, ...$errors);
        }
        return $allErrors;
    }
}
<!-- etc/di.xml - configure validators and their order -->
<type name="Vendor\Module\Model\OrderValidationChain">
    <arguments>
        <argument name="validators" xsi:type="array">
            <item name="minimum_value" xsi:type="array">
                <item name="validator" xsi:type="object">
                    Vendor\Module\Model\Validator\MinimumOrderValueValidator
                </item>
                <item name="sortOrder" xsi:type="number">10</item>
            </item>
            <item name="stock" xsi:type="array">
                <item name="validator" xsi:type="object">
                    Vendor\Module\Model\Validator\StockAvailabilityValidator
                </item>
                <item name="sortOrder" xsi:type="number">20</item>
            </item>
            <item name="credit" xsi:type="array">
                <item name="validator" xsi:type="object">
                    Vendor\Module\Model\Validator\CustomerCreditValidator
                </item>
                <item name="sortOrder" xsi:type="number">30</item>
            </item>
        </argument>
    </arguments>
</type>

Any module can add a validator by declaring an additional item in its own di.xml with the desired sortOrder – no modification to the chain class needed.

Testing the chain

<?php

use PHPUnit\Framework\TestCase;

class OrderValidationChainTest extends TestCase
{
    public function testPassesWhenAllValidatorsAccept(): void
    {
        $v1 = $this->createMock(OrderValidatorInterface::class);
        $v1->method('validate')->willReturn([]);
        $v2 = $this->createMock(OrderValidatorInterface::class);
        $v2->method('validate')->willReturn([]);

        $chain = new OrderValidationChain([
            ['validator' => $v1, 'sortOrder' => 10],
            ['validator' => $v2, 'sortOrder' => 20],
        ]);

        $this->assertEmpty($chain->validate(['total' => 100.0]));
    }

    public function testStopsAtFirstFailure(): void
    {
        $v1 = $this->createMock(OrderValidatorInterface::class);
        $v1->method('validate')->willReturn(['Too cheap']);

        $v2 = $this->createMock(OrderValidatorInterface::class);
        $v2->expects($this->never())->method('validate'); // never reached

        $chain = new OrderValidationChain([
            ['validator' => $v1, 'sortOrder' => 10],
            ['validator' => $v2, 'sortOrder' => 20],
        ]);

        $errors = $chain->validate(['total' => 1.0]);
        $this->assertEquals(['Too cheap'], $errors);
    }
}

Summary

Chain of Responsibility decouples request senders from handlers. The Magento 2 di.xml approach with sortOrder is the idiomatic way to implement it on the platform – any module can inject a new handler without touching the chain class. The pattern is also used in Magento itself: payment method validators, address validators, and the cart price rule processing all use similar chains.

About Henryk Tews

What you can read next

Singleton and Builder in PHP – creational patterns
Proxy pattern – lazy loading, access control, caching, Proxy in Magento 2
Event Sourcing – Domain Events, Aggregate Root, Event Store, connecting with CQRS

© 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}