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.
