Chain of Responsibility is a behavioural pattern in which a request passes through a chain of handlers – each can handle it, modify it, or pass it on. Ideal for multi-step validation, data processing pipelines and middleware. In Magento 2 this pattern is everywhere – in plugins, order pipelines and HTTP request handling. I show an implementation from scratch and three different chain variants.
Three variants of the pattern
- Classic – handler handles OR passes on (one or the other)
- Pipeline – each handler processes and passes on (middleware)
- Intercepting filter – handler can stop the chain by returning an error
Classic implementation – order validation
<?php
declare(strict_types=1);
interface OrderValidatorInterface
{
public function setNext(OrderValidatorInterface $validator): OrderValidatorInterface;
public function validate(array $orderData): ValidationResult;
}
final class ValidationResult
{
private array $errors = [];
public function addError(string $field, string $message): void
{
$this->errors[$field][] = $message;
}
public function isValid(): bool { return empty($this->errors); }
public function getErrors(): array { return $this->errors; }
}
abstract class AbstractOrderValidator implements OrderValidatorInterface
{
private ?OrderValidatorInterface $next = null;
public function setNext(OrderValidatorInterface $validator): OrderValidatorInterface
{
$this->next = $validator;
return $validator; // enables chaining: $a->setNext($b)->setNext($c)
}
protected function validateNext(array $orderData, ValidationResult $result): void
{
if ($this->next !== null) {
$nextResult = $this->next->validate($orderData);
foreach ($nextResult->getErrors() as $field => $messages) {
foreach ($messages as $message) {
$result->addError($field, $message);
}
}
}
}
abstract public function validate(array $orderData): ValidationResult;
}
class CustomerValidator extends AbstractOrderValidator
{
public function validate(array $orderData): ValidationResult
{
$result = new ValidationResult();
if (empty($orderData['customer_email'])) {
$result->addError('customer_email', 'Customer email is required');
} elseif (!filter_var($orderData['customer_email'], FILTER_VALIDATE_EMAIL)) {
$result->addError('customer_email', 'Invalid email format');
}
if (empty($orderData['customer_firstname'])) {
$result->addError('customer_firstname', 'Customer first name is required');
}
$this->validateNext($orderData, $result);
return $result;
}
}
class AddressValidator extends AbstractOrderValidator
{
public function validate(array $orderData): ValidationResult
{
$result = new ValidationResult();
$address = $orderData['shipping_address'] ?? [];
if (empty($address['street'])) {
$result->addError('shipping_address.street', 'Street is required');
}
if (empty($address['postcode'])) {
$result->addError('shipping_address.postcode', 'Postcode is required');
}
$this->validateNext($orderData, $result);
return $result;
}
}
class ItemsValidator extends AbstractOrderValidator
{
public function validate(array $orderData): ValidationResult
{
$result = new ValidationResult();
$items = $orderData['items'] ?? [];
if (empty($items)) {
$result->addError('items', 'Order must contain at least one product');
}
foreach ($items as $index => $item) {
if (empty($item['sku'])) {
$result->addError("items.{$index}.sku", 'Product SKU is required');
}
if (($item['qty'] ?? 0) <= 0) {
$result->addError("items.{$index}.qty", 'Quantity must be greater than 0');
}
}
$this->validateNext($orderData, $result);
return $result;
}
}
class MinimumOrderAmountValidator extends AbstractOrderValidator
{
public function __construct(private float $minimumAmount = 50.0) {}
public function validate(array $orderData): ValidationResult
{
$result = new ValidationResult();
$total = $orderData['grand_total'] ?? 0.0;
if ($total < $this->minimumAmount) {
$result->addError(
'grand_total',
"Minimum order value is {$this->minimumAmount}"
);
}
$this->validateNext($orderData, $result);
return $result;
}
}
// Build the chain
$customerValidator = new CustomerValidator();
$customerValidator
->setNext(new AddressValidator())
->setNext(new ItemsValidator())
->setNext(new MinimumOrderAmountValidator(50.0));
// Usage
$orderData = [
'customer_email' => 'jan@example.com',
'customer_firstname'=> 'Jan',
'shipping_address' => ['street' => 'Test St 1', 'postcode' => '30-001'],
'items' => [['sku' => 'SKU-001', 'qty' => 2]],
'grand_total' => 149.99,
];
$result = $customerValidator->validate($orderData);
if (!$result->isValid()) {
foreach ($result->getErrors() as $field => $messages) {
echo "{$field}: " . implode(', ', $messages) . "\n";
}
}
Pipeline – HTTP middleware
<?php
declare(strict_types=1);
interface MiddlewareInterface
{
public function process(Request $request, callable $next): Response;
}
class Request
{
public array $attributes = [];
public function __construct(public string $method, public string $path, public array $headers = []) {}
}
class Response
{
public function __construct(public int $status, public string $body = '') {}
}
class AuthMiddleware implements MiddlewareInterface
{
public function process(Request $request, callable $next): Response
{
$token = $request->headers['Authorization'] ?? '';
if (empty($token)) {
return new Response(401, 'Unauthorized');
}
$request->attributes['user_id'] = $this->validateToken($token);
return $next($request);
}
private function validateToken(string $token): int { return 42; }
}
class RateLimitMiddleware implements MiddlewareInterface
{
public function process(Request $request, callable $next): Response
{
$userId = $request->attributes['user_id'] ?? 0;
if ($this->isRateLimited($userId)) {
return new Response(429, 'Too Many Requests');
}
return $next($request);
}
private function isRateLimited(int $userId): bool { return false; }
}
class LoggingMiddleware implements MiddlewareInterface
{
public function process(Request $request, callable $next): Response
{
$start = microtime(true);
$response = $next($request);
$duration = round((microtime(true) - $start) * 1000, 2);
error_log("{$request->method} {$request->path} {$response->status} {$duration}ms");
return $response;
}
}
class Pipeline
{
private array $middleware = [];
public function pipe(MiddlewareInterface $middleware): static
{
$this->middleware[] = $middleware;
return $this;
}
public function run(Request $request, callable $handler): Response
{
$chain = array_reduce(
array_reverse($this->middleware),
fn($next, $middleware) => fn($req) => $middleware->process($req, $next),
$handler
);
return $chain($request);
}
}
// Usage
$pipeline = (new Pipeline())
->pipe(new LoggingMiddleware())
->pipe(new AuthMiddleware())
->pipe(new RateLimitMiddleware());
$response = $pipeline->run(
new Request('GET', '/api/orders', ['Authorization' => 'Bearer token123']),
fn($req) => new Response(200, json_encode(['orders' => []]))
);
Chain of Responsibility in Magento 2
<?php
// Composite pattern + Chain of Responsibility
// Magento 2 Total Collectors are a CoR example - each collector adds to totals
class CompositeOrderValidator implements OrderValidatorInterface
{
/** @var OrderValidatorInterface[] */
private array $validators;
public function __construct(array $validators)
{
$this->validators = $validators;
}
public function validate(array $orderData): ValidationResult
{
$combined = new ValidationResult();
foreach ($this->validators as $validator) {
$result = $validator->validate($orderData);
if (!$result->isValid()) {
foreach ($result->getErrors() as $field => $messages) {
foreach ($messages as $message) {
$combined->addError($field, $message);
}
}
}
}
return $combined;
}
public function setNext(OrderValidatorInterface $v): OrderValidatorInterface { return $v; }
}
Summary
Chain of Responsibility solves the problem of multi-step processing without tight coupling between steps. Classic variant – handler handles or passes on. Pipeline – each handler processes and must pass on. Composite validator – all handlers execute in parallel. In Magento 2 this pattern appears in total collectors, plugin pipeline (around plugins form a chain), and request handling. Next post: Custom Payment Method – integrating your own payment gateway.
