PHP / Magento Dev Blog

  • Home

Chain of Responsibility – multi-step validation, pipeline, middleware

by Henryk Tews / Wednesday, 17 June 2026 / Published in Wzorce projektowe

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.

About Henryk Tews

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