MidТеория25 min

Поведенческие паттерны

Strategy, Observer, Command, Chain of Responsibility, Template Method, State, Iterator, Mediator, Memento, Visitor на PHP 8.4

Поведенческие паттерны (Behavioral Patterns)

Поведенческие паттерны определяют алгоритмы и способы взаимодействия между объектами, распределяя обязанности и потоки управления.

Strategy

Проблема

Алгоритм должен меняться в зависимости от контекста. Множество if/else или switch для выбора поведения превращают код в спагетти.

Решение

<?php
declare(strict_types=1);

// Strategy interface
interface PricingStrategy
{
    public function calculate(float $basePrice, int $quantity): float;
}

// Concrete strategies
final readonly class RegularPricing implements PricingStrategy
{
    public function calculate(float $basePrice, int $quantity): float
    {
        return $basePrice * $quantity;
    }
}

final readonly class BulkPricing implements PricingStrategy
{
    public function __construct(
        private int $bulkThreshold = 10,
        private float $discountPercent = 15.0,
    ) {}

    public function calculate(float $basePrice, int $quantity): float
    {
        $total = $basePrice * $quantity;

        if ($quantity >= $this->bulkThreshold) {
            $total *= (1 - $this->discountPercent / 100);
        }

        return round($total, 2);
    }
}

final readonly class VipPricing implements PricingStrategy
{
    public function __construct(
        private float $discountPercent = 20.0,
    ) {}

    public function calculate(float $basePrice, int $quantity): float
    {
        $total = $basePrice * $quantity;
        return round($total * (1 - $this->discountPercent / 100), 2);
    }
}

// Context uses strategy
final class ShoppingCart
{
    /** @var list<array{name: string, price: float, quantity: int}> */
    private array $items = [];

    public function __construct(
        private PricingStrategy $pricing,
    ) {}

    public function setPricing(PricingStrategy $pricing): void
    {
        $this->pricing = $pricing;
    }

    public function addItem(string $name, float $price, int $quantity): void
    {
        $this->items[] = ['name' => $name, 'price' => $price, 'quantity' => $quantity];
    }

    public function getTotal(): float
    {
        $total = 0.0;

        foreach ($this->items as $item) {
            $total += $this->pricing->calculate($item['price'], $item['quantity']);
        }

        return $total;
    }
}

// Switch strategy at runtime
$cart = new ShoppingCart(new RegularPricing());
$cart->addItem('Widget', 9.99, 5);

echo $cart->getTotal(); // 49.95

$cart->setPricing(new VipPricing());
echo $cart->getTotal(); // 39.96

PHP 8.4: Strategy через enum и first-class callables

<?php
declare(strict_types=1);

// Strategy via enum with backed values
enum SortStrategy: string
{
    case ByName = 'name';
    case ByPrice = 'price';
    case ByDate = 'date';

    public function comparator(): \Closure
    {
        return match ($this) {
            self::ByName => fn(array $a, array $b): int => $a['name'] <=> $b['name'],
            self::ByPrice => fn(array $a, array $b): int => $a['price'] <=> $b['price'],
            self::ByDate => fn(array $a, array $b): int => $a['date'] <=> $b['date'],
        };
    }
}

// Usage
$products = [
    ['name' => 'Widget', 'price' => 29.99, 'date' => '2025-01-15'],
    ['name' => 'Gadget', 'price' => 19.99, 'date' => '2025-03-01'],
];

$strategy = SortStrategy::from('price');
usort($products, $strategy->comparator());

Когда использовать

  • Нужно переключать алгоритм в рантайме
  • Есть несколько вариантов обработки одних и тех же данных
  • Хотите избавиться от цепочки условий

В Symfony

<?php
declare(strict_types=1);

use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;

// Symfony Security Voters -- Strategy pattern
// Each voter encapsulates authorization logic for specific resource type
final class OrderVoter extends Voter
{
    protected function supports(string $attribute, mixed $subject): bool
    {
        return $subject instanceof Order && in_array($attribute, ['VIEW', 'EDIT']);
    }

    protected function voteOnAttribute(
        string $attribute,
        mixed $subject,
        TokenInterface $token,
    ): bool {
        $user = $token->getUser();

        return match ($attribute) {
            'VIEW' => true,
            'EDIT' => $subject->getOwnerId() === $user->getId(),
            default => false,
        };
    }
}

Observer

Проблема

Объект должен уведомлять другие объекты об изменениях своего состояния, но не знать о них напрямую. Жёсткая связь между компонентами делает код хрупким.

Решение

<?php
declare(strict_types=1);

// Event object
final readonly class OrderCreatedEvent
{
    public function __construct(
        public int $orderId,
        public int $customerId,
        public float $total,
        public \DateTimeImmutable $createdAt = new \DateTimeImmutable(),
    ) {}
}

// Observer interface
interface EventListener
{
    public function handle(object $event): void;
}

// Concrete observers
final readonly class SendOrderConfirmation implements EventListener
{
    public function handle(object $event): void
    {
        if (!$event instanceof OrderCreatedEvent) {
            return;
        }

        echo "Sending confirmation email for order #{$event->orderId}\n";
    }
}

final readonly class UpdateInventory implements EventListener
{
    public function handle(object $event): void
    {
        if (!$event instanceof OrderCreatedEvent) {
            return;
        }

        echo "Updating inventory for order #{$event->orderId}\n";
    }
}

final readonly class NotifyWarehouse implements EventListener
{
    public function handle(object $event): void
    {
        if (!$event instanceof OrderCreatedEvent) {
            return;
        }

        echo "Notifying warehouse about order #{$event->orderId}\n";
    }
}

// Event dispatcher (Subject)
final class EventDispatcher
{
    /** @var array<class-string, list<EventListener>> */
    private array $listeners = [];

    public function subscribe(string $eventClass, EventListener $listener): void
    {
        $this->listeners[$eventClass][] = $listener;
    }

    public function dispatch(object $event): void
    {
        $eventClass = $event::class;

        foreach ($this->listeners[$eventClass] ?? [] as $listener) {
            $listener->handle($event);
        }
    }
}

// Wire it up
$dispatcher = new EventDispatcher();
$dispatcher->subscribe(OrderCreatedEvent::class, new SendOrderConfirmation());
$dispatcher->subscribe(OrderCreatedEvent::class, new UpdateInventory());
$dispatcher->subscribe(OrderCreatedEvent::class, new NotifyWarehouse());

// Fire event -- all listeners get notified
$dispatcher->dispatch(new OrderCreatedEvent(
    orderId: 1,
    customerId: 42,
    total: 99.99,
));

Когда использовать

  • Изменение одного объекта требует реакции других
  • Набор реагирующих объектов неизвестен заранее или меняется
  • Хотите слабое связывание между компонентами

В Symfony

<?php
declare(strict_types=1);

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

// Symfony EventDispatcher -- native Observer implementation
// Listeners are auto-registered via attributes

#[AsEventListener(event: OrderCreatedEvent::class, priority: 10)]
final readonly class SendConfirmationListener
{
    public function __invoke(OrderCreatedEvent $event): void
    {
        // Send email for order #{$event->orderId}
    }
}

#[AsEventListener(event: OrderCreatedEvent::class, priority: 5)]
final readonly class UpdateInventoryListener
{
    public function __invoke(OrderCreatedEvent $event): void
    {
        // Update stock levels
    }
}

// In service: dispatch event
// $this->eventDispatcher->dispatch(new OrderCreatedEvent(...));

Command

Проблема

Нужно инкапсулировать запрос как объект: для отложенного выполнения, очереди команд, отмены операций (undo), логирования действий.

Решение

<?php
declare(strict_types=1);

// Command interface
interface Command
{
    public function execute(): void;
    public function undo(): void;
}

// Receiver
final class TextEditor
{
    private string $content = '';

    public function insertText(string $text, int $position): void
    {
        $this->content = substr($this->content, 0, $position)
            . $text
            . substr($this->content, $position);
    }

    public function deleteText(int $position, int $length): void
    {
        $this->content = substr($this->content, 0, $position)
            . substr($this->content, $position + $length);
    }

    public function getContent(): string
    {
        return $this->content;
    }
}

// Concrete commands
final class InsertTextCommand implements Command
{
    public function __construct(
        private readonly TextEditor $editor,
        private readonly string $text,
        private readonly int $position,
    ) {}

    public function execute(): void
    {
        $this->editor->insertText($this->text, $this->position);
    }

    public function undo(): void
    {
        $this->editor->deleteText($this->position, strlen($this->text));
    }
}

// Command history (Invoker)
final class CommandHistory
{
    /** @var list<Command> */
    private array $history = [];
    private int $pointer = -1;

    public function execute(Command $command): void
    {
        // Remove any "future" commands after current pointer
        $this->history = array_slice($this->history, 0, $this->pointer + 1);

        $command->execute();
        $this->history[] = $command;
        $this->pointer++;
    }

    public function undo(): void
    {
        if ($this->pointer < 0) {
            return;
        }

        $this->history[$this->pointer]->undo();
        $this->pointer--;
    }

    public function redo(): void
    {
        if ($this->pointer >= count($this->history) - 1) {
            return;
        }

        $this->pointer++;
        $this->history[$this->pointer]->execute();
    }
}

// Usage
$editor = new TextEditor();
$history = new CommandHistory();

$history->execute(new InsertTextCommand($editor, 'Hello', 0));
$history->execute(new InsertTextCommand($editor, ' World', 5));
echo $editor->getContent(); // "Hello World"

$history->undo();
echo $editor->getContent(); // "Hello"

$history->redo();
echo $editor->getContent(); // "Hello World"

Когда использовать

  • Отложенное или запланированное выполнение операций
  • Операции с undo/redo
  • Очередь команд (job queue)
  • Логирование и аудит действий

В Symfony

<?php
declare(strict_types=1);

use Symfony\Component\Messenger\Attribute\AsMessageHandler;

// Symfony Messenger -- Command pattern for async processing
// Command (message)
final readonly class CreateOrderCommand
{
    public function __construct(
        public int $customerId,
        public array $items,
    ) {}
}

// Handler
#[AsMessageHandler]
final readonly class CreateOrderHandler
{
    public function __construct(
        private OrderRepository $orders,
    ) {}

    public function __invoke(CreateOrderCommand $command): void
    {
        $this->orders->create(
            customerId: $command->customerId,
            items: $command->items,
        );
    }
}

// Dispatch via message bus
// $this->messageBus->dispatch(new CreateOrderCommand(customerId: 1, items: [...]));

Chain of Responsibility

Проблема

Запрос должен пройти через цепочку обработчиков, где каждый решает: обработать самому или передать следующему.

Решение

<?php
declare(strict_types=1);

// Request DTO
final class HttpRequest
{
    public function __construct(
        public readonly string $method,
        public readonly string $path,
        public readonly array $headers = [],
        public ?int $userId = null,
        public ?string $body = null,
    ) {}
}

// Handler interface
interface Middleware
{
    public function handle(HttpRequest $request, \Closure $next): mixed;
}

// Concrete handlers
final readonly class RateLimitMiddleware implements Middleware
{
    /** @var array<string, int> */
    private static array $requests = [];

    public function handle(HttpRequest $request, \Closure $next): mixed
    {
        $ip = $request->headers['X-Real-IP'] ?? '127.0.0.1';
        $count = (self::$requests[$ip] ?? 0) + 1;

        if ($count > 100) {
            return ['error' => 'Rate limit exceeded', 'code' => 429];
        }

        return $next($request);
    }
}

final readonly class AuthMiddleware implements Middleware
{
    public function handle(HttpRequest $request, \Closure $next): mixed
    {
        $token = $request->headers['Authorization'] ?? '';

        if (!str_starts_with($token, 'Bearer ')) {
            return ['error' => 'Unauthorized', 'code' => 401];
        }

        // Validate token and set user
        $request->userId = 42; // Parsed from token

        return $next($request);
    }
}

final readonly class LoggingMiddleware implements Middleware
{
    public function handle(HttpRequest $request, \Closure $next): mixed
    {
        $start = microtime(true);
        echo "[LOG] {$request->method} {$request->path}\n";

        $response = $next($request);

        $duration = round((microtime(true) - $start) * 1000, 2);
        echo "[LOG] Completed in {$duration}ms\n";

        return $response;
    }
}

// Pipeline (Chain builder)
final class Pipeline
{
    /** @var list<Middleware> */
    private array $middlewares = [];

    public function pipe(Middleware $middleware): self
    {
        $this->middlewares[] = $middleware;
        return $this;
    }

    public function process(HttpRequest $request, \Closure $handler): mixed
    {
        $pipeline = array_reduce(
            array_reverse($this->middlewares),
            fn(\Closure $next, Middleware $middleware): \Closure =>
                fn(HttpRequest $req): mixed => $middleware->handle($req, $next),
            $handler,
        );

        return $pipeline($request);
    }
}

// Build chain
$pipeline = new Pipeline();
$pipeline
    ->pipe(new LoggingMiddleware())
    ->pipe(new RateLimitMiddleware())
    ->pipe(new AuthMiddleware());

// Process request through chain
$response = $pipeline->process(
    new HttpRequest('GET', '/api/orders', ['Authorization' => 'Bearer token123']),
    fn(HttpRequest $req): array => ['data' => 'Orders list', 'userId' => $req->userId],
);

Когда использовать

  • Запрос обрабатывается серией обработчиков
  • Набор и порядок обработчиков определяется динамически
  • HTTP middleware, валидация, фильтрация

В Symfony

<?php
declare(strict_types=1);

// Symfony HttpKernel -- Chain of Responsibility via event listeners
// Request goes through: Firewall → Router → Controller → Response

// Custom middleware via kernel event
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

#[AsEventListener(event: 'kernel.request', priority: 256)]
final readonly class MaintenanceModeListener
{
    public function __invoke(RequestEvent $event): void
    {
        if ($this->isMaintenanceMode()) {
            $event->setResponse(new JsonResponse(
                ['error' => 'Service is under maintenance'],
                503,
            ));
            // Stops the chain -- no further listeners are called
        }
    }

    private function isMaintenanceMode(): bool
    {
        return file_exists('/tmp/maintenance.flag');
    }
}

Template Method

Проблема

Несколько классов выполняют похожий алгоритм, но отличаются в деталях отдельных шагов. Дублирование общей логики -- проблема.

Решение

<?php
declare(strict_types=1);

// Abstract class defines the skeleton
abstract class DataExporter
{
    // Template method -- final to prevent overriding the skeleton
    final public function export(array $data): string
    {
        $filtered = $this->filterData($data);
        $formatted = $this->formatData($filtered);
        $output = $this->addHeader() . $formatted . $this->addFooter();

        return $output;
    }

    // Steps to be overridden by subclasses
    abstract protected function formatData(array $data): string;
    abstract protected function addHeader(): string;

    // Hook methods -- optional override
    protected function filterData(array $data): array
    {
        return $data; // Default: no filtering
    }

    protected function addFooter(): string
    {
        return ''; // Default: no footer
    }
}

// Concrete implementation: CSV
final class CsvExporter extends DataExporter
{
    protected function formatData(array $data): string
    {
        $lines = [];

        foreach ($data as $row) {
            $lines[] = implode(',', array_map(
                fn(mixed $v): string => '"' . str_replace('"', '""', (string) $v) . '"',
                $row,
            ));
        }

        return implode("\n", $lines) . "\n";
    }

    protected function addHeader(): string
    {
        return "name,email,role\n";
    }
}

// Concrete implementation: JSON
final class JsonExporter extends DataExporter
{
    protected function formatData(array $data): string
    {
        return json_encode($data, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
    }

    protected function addHeader(): string
    {
        return ''; // JSON is self-describing
    }

    protected function filterData(array $data): array
    {
        // Remove sensitive fields
        return array_map(
            fn(array $row): array => array_diff_key($row, ['password' => true]),
            $data,
        );
    }
}

// Same algorithm, different implementations
$data = [
    ['name' => 'Alice', 'email' => '[email protected]', 'role' => 'admin'],
    ['name' => 'Bob', 'email' => '[email protected]', 'role' => 'user'],
];

$csv = new CsvExporter();
echo $csv->export($data);

$json = new JsonExporter();
echo $json->export($data);

Когда использовать

  • Есть алгоритм с фиксированной структурой, но вариативными шагами
  • Нужно избежать дублирования общего кода
  • Хотите контролировать точки расширения

В Symfony

<?php
declare(strict_types=1);

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;

// AbstractController -- Template Method pattern
// It provides the "skeleton" (render, json, redirectToRoute, etc.)
// You override specific "steps" (action methods)
final class OrderController extends AbstractController
{
    public function list(): Response
    {
        // Using inherited template methods
        return $this->render('order/list.html.twig', [
            'orders' => [], // load orders...
        ]);
    }
}

State

Проблема

Поведение объекта зависит от его состояния. Множество if/switch по состоянию в каждом методе делают код запутанным.

Решение

<?php
declare(strict_types=1);

// State interface
interface OrderState
{
    public function confirm(Order $order): void;
    public function ship(Order $order): void;
    public function deliver(Order $order): void;
    public function cancel(Order $order): void;
    public function label(): string;
}

// Concrete states
final class DraftState implements OrderState
{
    public function confirm(Order $order): void
    {
        echo "Order confirmed. Processing payment...\n";
        $order->setState(new ConfirmedState());
    }

    public function ship(Order $order): void
    {
        throw new \LogicException('Cannot ship a draft order');
    }

    public function deliver(Order $order): void
    {
        throw new \LogicException('Cannot deliver a draft order');
    }

    public function cancel(Order $order): void
    {
        echo "Draft order cancelled\n";
        $order->setState(new CancelledState());
    }

    public function label(): string
    {
        return 'draft';
    }
}

final class ConfirmedState implements OrderState
{
    public function confirm(Order $order): void
    {
        throw new \LogicException('Order already confirmed');
    }

    public function ship(Order $order): void
    {
        echo "Order shipped. Tracking created...\n";
        $order->setState(new ShippedState());
    }

    public function deliver(Order $order): void
    {
        throw new \LogicException('Cannot deliver before shipping');
    }

    public function cancel(Order $order): void
    {
        echo "Confirmed order cancelled. Refund initiated...\n";
        $order->setState(new CancelledState());
    }

    public function label(): string
    {
        return 'confirmed';
    }
}

final class ShippedState implements OrderState
{
    public function confirm(Order $order): void
    {
        throw new \LogicException('Order already confirmed and shipped');
    }

    public function ship(Order $order): void
    {
        throw new \LogicException('Already shipped');
    }

    public function deliver(Order $order): void
    {
        echo "Order delivered!\n";
        $order->setState(new DeliveredState());
    }

    public function cancel(Order $order): void
    {
        throw new \LogicException('Cannot cancel shipped order');
    }

    public function label(): string
    {
        return 'shipped';
    }
}

final class DeliveredState implements OrderState
{
    public function confirm(Order $order): void
    {
        throw new \LogicException('Order completed');
    }

    public function ship(Order $order): void
    {
        throw new \LogicException('Order completed');
    }

    public function deliver(Order $order): void
    {
        throw new \LogicException('Already delivered');
    }

    public function cancel(Order $order): void
    {
        throw new \LogicException('Cannot cancel delivered order');
    }

    public function label(): string
    {
        return 'delivered';
    }
}

final class CancelledState implements OrderState
{
    public function confirm(Order $order): void
    {
        throw new \LogicException('Order is cancelled');
    }

    public function ship(Order $order): void
    {
        throw new \LogicException('Order is cancelled');
    }

    public function deliver(Order $order): void
    {
        throw new \LogicException('Order is cancelled');
    }

    public function cancel(Order $order): void
    {
        throw new \LogicException('Already cancelled');
    }

    public function label(): string
    {
        return 'cancelled';
    }
}

// Context
final class Order
{
    private OrderState $state;

    public function __construct()
    {
        $this->state = new DraftState();
    }

    public function setState(OrderState $state): void
    {
        $this->state = $state;
    }

    public function getStatus(): string
    {
        return $this->state->label();
    }

    // Delegate to current state
    public function confirm(): void { $this->state->confirm($this); }
    public function ship(): void { $this->state->ship($this); }
    public function deliver(): void { $this->state->deliver($this); }
    public function cancel(): void { $this->state->cancel($this); }
}

// Usage: each method behaves differently based on state
$order = new Order();
echo $order->getStatus(); // "draft"

$order->confirm();        // "Order confirmed..."
echo $order->getStatus(); // "confirmed"

$order->ship();           // "Order shipped..."
$order->deliver();        // "Order delivered!"

PHP 8.4: State через enum

<?php
declare(strict_types=1);

enum OrderStatus: string
{
    case Draft = 'draft';
    case Confirmed = 'confirmed';
    case Shipped = 'shipped';
    case Delivered = 'delivered';
    case Cancelled = 'cancelled';

    public function canTransitionTo(self $target): bool
    {
        return match ($this) {
            self::Draft => in_array($target, [self::Confirmed, self::Cancelled]),
            self::Confirmed => in_array($target, [self::Shipped, self::Cancelled]),
            self::Shipped => $target === self::Delivered,
            self::Delivered, self::Cancelled => false,
        };
    }

    public function isFinal(): bool
    {
        return match ($this) {
            self::Delivered, self::Cancelled => true,
            default => false,
        };
    }
}

Когда использовать

  • Объект ведёт себя по-разному в зависимости от состояния
  • Много условной логики, завязанной на состояние
  • Состояния и переходы между ними сложны

В Symfony

<?php
declare(strict_types=1);

// Symfony Workflow component -- State machine
// config/packages/workflow.yaml
// framework:
//   workflows:
//     order:
//       type: state_machine
//       marking_store:
//         type: method
//         property: status
//       places: [draft, confirmed, shipped, delivered]
//       transitions:
//         confirm: { from: draft, to: confirmed }
//         ship: { from: confirmed, to: shipped }
//         deliver: { from: shipped, to: delivered }

Iterator

Проблема

Нужно обходить коллекцию объектов без раскрытия её внутренней структуры. Разные обходы (прямой, обратный, по условию) не должны загрязнять класс коллекции.

Решение

<?php
declare(strict_types=1);

// Custom collection with Iterator
final class UserCollection implements \IteratorAggregate, \Countable
{
    /** @var list<UserDto> */
    private array $users = [];

    public function add(UserDto $user): void
    {
        $this->users[] = $user;
    }

    public function count(): int
    {
        return count($this->users);
    }

    public function getIterator(): \ArrayIterator
    {
        return new \ArrayIterator($this->users);
    }

    // Filtered iterator
    public function activeUsers(): \Iterator
    {
        return new \CallbackFilterIterator(
            $this->getIterator(),
            fn(UserDto $user): bool => $user->active,
        );
    }

    // Mapped iterator
    public function emails(): \Iterator
    {
        return new \ArrayIterator(
            array_map(fn(UserDto $u): string => $u->email, $this->users),
        );
    }
}

final readonly class UserDto
{
    public function __construct(
        public int $id,
        public string $name,
        public string $email,
        public bool $active = true,
    ) {}
}

// Usage
$users = new UserCollection();
$users->add(new UserDto(1, 'Alice', '[email protected]', true));
$users->add(new UserDto(2, 'Bob', '[email protected]', false));
$users->add(new UserDto(3, 'Charlie', '[email protected]', true));

// Iterate without knowing internal structure
foreach ($users as $user) {
    echo "{$user->name}\n";
}

// Use filtered view
foreach ($users->activeUsers() as $user) {
    echo "Active: {$user->name}\n"; // Alice, Charlie
}

echo "Total: {$users->count()}\n"; // 3

PHP 8.4: Generator-based iterator

<?php
declare(strict_types=1);

// Memory-efficient iteration with generators
final readonly class CsvReader implements \IteratorAggregate
{
    public function __construct(
        private string $filePath,
    ) {}

    public function getIterator(): \Generator
    {
        $handle = fopen($this->filePath, 'r');

        if ($handle === false) {
            throw new \RuntimeException("Cannot open file: {$this->filePath}");
        }

        try {
            $headers = fgetcsv($handle);

            while (($row = fgetcsv($handle)) !== false) {
                // Yield one row at a time -- constant memory
                yield array_combine($headers, $row);
            }
        } finally {
            fclose($handle);
        }
    }

    // Chainable filtered iteration
    public function where(string $column, mixed $value): \Generator
    {
        foreach ($this as $row) {
            if (($row[$column] ?? null) === $value) {
                yield $row;
            }
        }
    }
}

// Process millions of rows with constant memory
$csv = new CsvReader('/data/users.csv');

foreach ($csv->where('role', 'admin') as $admin) {
    echo $admin['name'] . "\n";
}

Когда использовать

  • Обход коллекции без раскрытия структуры
  • Несколько способов обхода одной коллекции
  • Единообразный интерфейс для разных структур данных

В Symfony

<?php
declare(strict_types=1);

use Symfony\Component\Finder\Finder;

// Symfony Finder -- powerful file system Iterator
$finder = new Finder();
$finder
    ->files()
    ->in('/src')
    ->name('*.php')
    ->depth('< 3')
    ->sortByName();

foreach ($finder as $file) {
    echo $file->getRelativePathname() . "\n";
}

Mediator

Проблема

Множество объектов взаимодействуют друг с другом напрямую, создавая паутину зависимостей. Каждый компонент знает о десятках других, и изменение одного ломает цепочку. Например, в чате каждый участник должен знать обо всех остальных, а в UI-форме кнопка зависит от поля ввода, которое зависит от чекбокса.

Решение

<?php
declare(strict_types=1);

// Mediator interface
interface ChatMediator
{
    public function sendMessage(string $message, ChatUser $sender): void;
    public function addUser(ChatUser $user): void;
}

// Colleague base
abstract readonly class ChatUser
{
    public function __construct(
        public string $name,
        private ChatMediator $mediator,
    ) {}

    public function send(string $message): void
    {
        echo "[{$this->name}] sends: {$message}\n";
        $this->mediator->sendMessage($message, $this);
    }

    abstract public function receive(string $message, ChatUser $from): void;
}

// Concrete colleague: regular member
final readonly class RegularMember extends ChatUser
{
    public function receive(string $message, ChatUser $from): void
    {
        echo "[{$this->name}] received from {$from->name}: {$message}\n";
    }
}

// Concrete colleague: moderator with filtering
final readonly class Moderator extends ChatUser
{
    public function receive(string $message, ChatUser $from): void
    {
        echo "[MOD {$this->name}] monitoring from {$from->name}: {$message}\n";
    }
}

// Concrete mediator: chat room
final class ChatRoom implements ChatMediator
{
    /** @var list<ChatUser> */
    private array $users = [];

    /** @var list<array{time: \DateTimeImmutable, sender: string, message: string}> */
    private array $history = [];

    public function addUser(ChatUser $user): void
    {
        $this->users[] = $user;
        $this->sendSystemMessage("{$user->name} joined the chat");
    }

    public function sendMessage(string $message, ChatUser $sender): void
    {
        $this->history[] = [
            'time' => new \DateTimeImmutable(),
            'sender' => $sender->name,
            'message' => $message,
        ];

        // Mediator decides who receives the message
        foreach ($this->users as $user) {
            if ($user !== $sender) {
                $user->receive($message, $sender);
            }
        }
    }

    private function sendSystemMessage(string $message): void
    {
        foreach ($this->users as $user) {
            $user->receive("[SYSTEM] {$message}", $user);
        }
    }

    /** @return list<array{time: \DateTimeImmutable, sender: string, message: string}> */
    public function getHistory(): array
    {
        return $this->history;
    }
}

// Usage: users communicate ONLY through mediator
$room = new ChatRoom();

$alice = new RegularMember('Alice', $room);
$bob = new RegularMember('Bob', $room);
$admin = new Moderator('Admin', $room);

$room->addUser($alice);
$room->addUser($bob);
$room->addUser($admin);

$alice->send('Hello everyone!');
// [Alice] sends: Hello everyone!
// [Bob] received from Alice: Hello everyone!
// [MOD Admin] monitoring from Alice: Hello everyone!

$bob->send('Hey Alice!');
// [Bob] sends: Hey Alice!
// [Alice] received from Bob: Hey Alice!
// [MOD Admin] monitoring from Bob: Hey Alice!

PHP 8.4: Mediator для UI-формы

<?php
declare(strict_types=1);

// Form mediator: components communicate through dialog
interface FormMediator
{
    public function notify(FormComponent $sender, string $event): void;
}

// Abstract component knows only mediator
abstract class FormComponent
{
    public function __construct(
        protected readonly FormMediator $dialog,
    ) {}
}

final class TextInput extends FormComponent
{
    private string $value = '';

    public function setValue(string $value): void
    {
        $this->value = $value;
        $this->dialog->notify($this, 'input_changed');
    }

    public function getValue(): string
    {
        return $this->value;
    }
}

final class SubmitButton extends FormComponent
{
    private bool $enabled = false;

    public function click(): void
    {
        if ($this->enabled) {
            $this->dialog->notify($this, 'submit');
        }
    }

    public function setEnabled(bool $enabled): void
    {
        $this->enabled = $enabled;
        echo 'Submit button: ' . ($enabled ? 'enabled' : 'disabled') . "\n";
    }

    public function isEnabled(): bool
    {
        return $this->enabled;
    }
}

final class ValidationLabel extends FormComponent
{
    public function showError(string $message): void
    {
        echo "Validation: {$message}\n";
    }

    public function clear(): void
    {
        echo "Validation: OK\n";
    }
}

// Concrete mediator: registration dialog
final class RegistrationDialog implements FormMediator
{
    public readonly TextInput $emailInput;
    public readonly SubmitButton $submitButton;
    public readonly ValidationLabel $validation;

    public function __construct()
    {
        $this->emailInput = new TextInput($this);
        $this->submitButton = new SubmitButton($this);
        $this->validation = new ValidationLabel($this);
    }

    public function notify(FormComponent $sender, string $event): void
    {
        match (true) {
            $sender instanceof TextInput && $event === 'input_changed' => $this->onInputChanged(),
            $sender instanceof SubmitButton && $event === 'submit' => $this->onSubmit(),
            default => null,
        };
    }

    private function onInputChanged(): void
    {
        $email = $this->emailInput->getValue();
        $isValid = filter_var($email, FILTER_VALIDATE_EMAIL) !== false;

        if ($isValid) {
            $this->validation->clear();
            $this->submitButton->setEnabled(true);
        } else {
            $this->validation->showError('Invalid email format');
            $this->submitButton->setEnabled(false);
        }
    }

    private function onSubmit(): void
    {
        echo "Submitting form with email: {$this->emailInput->getValue()}\n";
    }
}

// Components do not know about each other
$dialog = new RegistrationDialog();
$dialog->emailInput->setValue('not-email');
// Validation: Invalid email format
// Submit button: disabled

$dialog->emailInput->setValue('[email protected]');
// Validation: OK
// Submit button: enabled

$dialog->submitButton->click();
// Submitting form with email: [email protected]

Когда использовать

  • Объекты тесно связаны множеством взаимных зависимостей
  • Повторное использование компонента невозможно из-за зависимости от других
  • Нужно централизовать сложную логику координации
  • Хотите менять взаимодействие компонентов без их изменения

В Symfony

<?php
declare(strict_types=1);

use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;

// Symfony EventDispatcher acts as Mediator
// Services don't communicate directly -- they go through the dispatcher

// Service A: publishes event (doesn't know about listeners)
final readonly class OrderService
{
    public function __construct(
        private EventDispatcherInterface $dispatcher,
    ) {}

    public function placeOrder(int $customerId, array $items): void
    {
        // Business logic...
        $orderId = random_int(1, 1000);

        // Notify through mediator -- doesn't know who listens
        $this->dispatcher->dispatch(new OrderPlacedEvent($orderId, $customerId));
    }
}

// Service B: reacts to event (doesn't know about OrderService)
#[AsEventListener(event: OrderPlacedEvent::class)]
final readonly class InventoryService
{
    public function __invoke(OrderPlacedEvent $event): void
    {
        // Reserve items for order #{$event->orderId}
    }
}

// Service C: also reacts (doesn't know about A or B)
#[AsEventListener(event: OrderPlacedEvent::class)]
final readonly class NotificationService
{
    public function __invoke(OrderPlacedEvent $event): void
    {
        // Send confirmation to customer #{$event->customerId}
    }
}

// EventDispatcher = Mediator that decouples all services

Memento

Проблема

Нужно сохранить состояние объекта, чтобы потом вернуть его к этому состоянию (undo, откат конфигурации). Но внутренние поля объекта приватны, и раскрывать их нарушает инкапсуляцию.

Решение

<?php
declare(strict_types=1);

// Memento: immutable snapshot of editor state
final readonly class EditorSnapshot
{
    public function __construct(
        private string $content,
        private int $cursorPosition,
        private string $fontFamily,
        private float $fontSize,
        private \DateTimeImmutable $createdAt = new \DateTimeImmutable(),
    ) {}

    // Only the originator can access these
    public function getContent(): string
    {
        return $this->content;
    }

    public function getCursorPosition(): int
    {
        return $this->cursorPosition;
    }

    public function getFontFamily(): string
    {
        return $this->fontFamily;
    }

    public function getFontSize(): float
    {
        return $this->fontSize;
    }

    public function getCreatedAt(): \DateTimeImmutable
    {
        return $this->createdAt;
    }

    public function getDescription(): string
    {
        $preview = mb_substr($this->content, 0, 30);
        return sprintf(
            '[%s] "%s..." (cursor: %d)',
            $this->createdAt->format('H:i:s'),
            $preview,
            $this->cursorPosition,
        );
    }
}

// Originator: the object whose state we save/restore
final class TextEditor
{
    private string $content = '';
    private int $cursorPosition = 0;
    private string $fontFamily = 'Arial';
    private float $fontSize = 12.0;

    public function type(string $text): void
    {
        $this->content = mb_substr($this->content, 0, $this->cursorPosition)
            . $text
            . mb_substr($this->content, $this->cursorPosition);
        $this->cursorPosition += mb_strlen($text);
    }

    public function moveCursor(int $position): void
    {
        $this->cursorPosition = max(0, min($position, mb_strlen($this->content)));
    }

    public function setFont(string $family, float $size): void
    {
        $this->fontFamily = $family;
        $this->fontSize = $size;
    }

    public function getContent(): string
    {
        return $this->content;
    }

    // Create memento
    public function save(): EditorSnapshot
    {
        return new EditorSnapshot(
            content: $this->content,
            cursorPosition: $this->cursorPosition,
            fontFamily: $this->fontFamily,
            fontSize: $this->fontSize,
        );
    }

    // Restore from memento
    public function restore(EditorSnapshot $snapshot): void
    {
        $this->content = $snapshot->getContent();
        $this->cursorPosition = $snapshot->getCursorPosition();
        $this->fontFamily = $snapshot->getFontFamily();
        $this->fontSize = $snapshot->getFontSize();
    }
}

// Caretaker: manages snapshot history
final class EditorHistory
{
    /** @var list<EditorSnapshot> */
    private array $undoStack = [];

    /** @var list<EditorSnapshot> */
    private array $redoStack = [];

    public function push(EditorSnapshot $snapshot): void
    {
        $this->undoStack[] = $snapshot;
        $this->redoStack = []; // Clear redo after new action
    }

    public function undo(TextEditor $editor): bool
    {
        if (count($this->undoStack) === 0) {
            return false;
        }

        // Save current state to redo stack
        $this->redoStack[] = $editor->save();

        // Restore previous state
        $snapshot = array_pop($this->undoStack);
        $editor->restore($snapshot);

        return true;
    }

    public function redo(TextEditor $editor): bool
    {
        if (count($this->redoStack) === 0) {
            return false;
        }

        // Save current state to undo stack
        $this->undoStack[] = $editor->save();

        // Restore redo state
        $snapshot = array_pop($this->redoStack);
        $editor->restore($snapshot);

        return true;
    }

    /** @return list<string> */
    public function getSnapshots(): array
    {
        return array_map(
            fn(EditorSnapshot $s): string => $s->getDescription(),
            $this->undoStack,
        );
    }
}

// Usage
$editor = new TextEditor();
$history = new EditorHistory();

// Type and save snapshots
$history->push($editor->save());
$editor->type('Hello');

$history->push($editor->save());
$editor->type(' World');

$history->push($editor->save());
$editor->type('! PHP 8.4 is great');

echo $editor->getContent(); // "Hello World! PHP 8.4 is great"

// Undo twice
$history->undo($editor);
echo $editor->getContent(); // "Hello World"

$history->undo($editor);
echo $editor->getContent(); // "Hello"

// Redo once
$history->redo($editor);
echo $editor->getContent(); // "Hello World"

PHP 8.4: Memento для конфигурации с property hooks

<?php
declare(strict_types=1);

// Configuration memento with rollback support
final readonly class ConfigSnapshot
{
    public function __construct(
        /** @var array<string, mixed> */
        public array $values,
        public string $label,
        public \DateTimeImmutable $savedAt = new \DateTimeImmutable(),
    ) {}
}

final class AppConfig
{
    /** @var array<string, mixed> */
    private array $values = [];

    /** @var list<ConfigSnapshot> */
    private array $checkpoints = [];

    public function set(string $key, mixed $value): void
    {
        $this->values[$key] = $value;
    }

    public function get(string $key, mixed $default = null): mixed
    {
        return $this->values[$key] ?? $default;
    }

    // Create named checkpoint
    public function checkpoint(string $label): void
    {
        $this->checkpoints[] = new ConfigSnapshot(
            values: $this->values,
            label: $label,
        );
    }

    // Rollback to specific checkpoint
    public function rollback(?string $label = null): bool
    {
        if (count($this->checkpoints) === 0) {
            return false;
        }

        if ($label === null) {
            $snapshot = array_pop($this->checkpoints);
            $this->values = $snapshot->values;
            return true;
        }

        // Find named checkpoint
        for ($i = count($this->checkpoints) - 1; $i >= 0; $i--) {
            if ($this->checkpoints[$i]->label === $label) {
                $this->values = $this->checkpoints[$i]->values;
                $this->checkpoints = array_slice($this->checkpoints, 0, $i);
                return true;
            }
        }

        return false;
    }

    /** @return list<string> */
    public function listCheckpoints(): array
    {
        return array_map(
            fn(ConfigSnapshot $s): string => sprintf('%s (%s)', $s->label, $s->savedAt->format('H:i:s')),
            $this->checkpoints,
        );
    }
}

// Usage: safe configuration changes with rollback
$config = new AppConfig();
$config->set('db.host', 'localhost');
$config->set('db.port', 5432);
$config->checkpoint('stable');

$config->set('db.host', 'new-server.prod');
$config->set('db.port', 5433);
$config->checkpoint('migration-attempt');

// Something went wrong -- rollback to stable
$config->rollback('stable');
echo $config->get('db.host'); // "localhost"
echo $config->get('db.port'); // 5432

Когда использовать

  • Нужен undo/redo без раскрытия внутренней структуры объекта
  • Откат конфигурации или транзакций
  • Сохранение контрольных точек (checkpoints) для восстановления
  • Протоколирование изменений состояния

В Symfony

<?php
declare(strict_types=1);

// Doctrine UnitOfWork -- uses Memento-like approach
// It tracks original entity state to compute changesets

// When entity is loaded, Doctrine saves a snapshot of its state:
// $originalEntityData[spl_object_id($entity)] = $snapshot;

// On flush, it compares current state with the saved snapshot
// to determine which fields changed -- pure Memento concept

use Doctrine\ORM\EntityManagerInterface;

final readonly class ConfigService
{
    public function __construct(
        private EntityManagerInterface $em,
    ) {}

    public function updateWithRollback(Setting $setting, string $newValue): void
    {
        // Doctrine internally stores original state (Memento)
        $setting->setValue($newValue);

        try {
            $this->em->flush();
        } catch (\Throwable) {
            // refresh() restores entity to saved snapshot
            $this->em->refresh($setting);
        }
    }
}

// Symfony Form: mapDataToForms / mapFormsToData
// Forms create data snapshots during submit handling:
// 1. Original data saved before form processing
// 2. If validation fails, original state is preserved
// 3. Only on success the new state is applied

Visitor

Проблема

Нужно добавлять операции к группе объектов разных типов, не изменяя их классы. Например, нужно генерировать отчёт, экспортировать в разные форматы или считать статистику по дереву объектов -- и каждая новая операция не должна требовать изменения самих классов.

Решение

<?php
declare(strict_types=1);

// Visitor interface: one method per element type
interface DocumentVisitor
{
    public function visitParagraph(Paragraph $paragraph): void;
    public function visitImage(Image $image): void;
    public function visitTable(Table $table): void;
    public function visitCodeBlock(CodeBlock $codeBlock): void;
}

// Element interface
interface DocumentNode
{
    public function accept(DocumentVisitor $visitor): void;
}

// Concrete elements: document nodes
final readonly class Paragraph implements DocumentNode
{
    public function __construct(
        public string $text,
        public int $wordCount,
    ) {}

    public function accept(DocumentVisitor $visitor): void
    {
        $visitor->visitParagraph($this);
    }
}

final readonly class Image implements DocumentNode
{
    public function __construct(
        public string $src,
        public string $alt,
        public int $widthPx,
        public int $heightPx,
    ) {}

    public function accept(DocumentVisitor $visitor): void
    {
        $visitor->visitImage($this);
    }

    public function getSizeKb(): float
    {
        // Rough estimate
        return round($this->widthPx * $this->heightPx * 3 / 1024, 1);
    }
}

final readonly class Table implements DocumentNode
{
    /** @param list<list<string>> $rows */
    public function __construct(
        public array $headers,
        public array $rows,
    ) {}

    public function accept(DocumentVisitor $visitor): void
    {
        $visitor->visitTable($this);
    }

    public function getRowCount(): int
    {
        return count($this->rows);
    }
}

final readonly class CodeBlock implements DocumentNode
{
    public function __construct(
        public string $code,
        public string $language,
        public int $lineCount,
    ) {}

    public function accept(DocumentVisitor $visitor): void
    {
        $visitor->visitCodeBlock($this);
    }
}

// Concrete visitor 1: HTML export
final class HtmlExportVisitor implements DocumentVisitor
{
    private string $html = '';

    public function visitParagraph(Paragraph $paragraph): void
    {
        $this->html .= "<p>{$paragraph->text}</p>\n";
    }

    public function visitImage(Image $image): void
    {
        $this->html .= "<img src=\"{$image->src}\" alt=\"{$image->alt}\" "
            . "width=\"{$image->widthPx}\" height=\"{$image->heightPx}\" />\n";
    }

    public function visitTable(Table $table): void
    {
        $this->html .= "<table>\n<thead><tr>";

        foreach ($table->headers as $header) {
            $this->html .= "<th>{$header}</th>";
        }

        $this->html .= "</tr></thead>\n<tbody>\n";

        foreach ($table->rows as $row) {
            $this->html .= '<tr>';
            foreach ($row as $cell) {
                $this->html .= "<td>{$cell}</td>";
            }
            $this->html .= "</tr>\n";
        }

        $this->html .= "</tbody>\n</table>\n";
    }

    public function visitCodeBlock(CodeBlock $codeBlock): void
    {
        $this->html .= "<pre><code class=\"language-{$codeBlock->language}\">"
            . htmlspecialchars($codeBlock->code)
            . "</code></pre>\n";
    }

    public function getResult(): string
    {
        return $this->html;
    }
}

// Concrete visitor 2: document statistics
final class StatisticsVisitor implements DocumentVisitor
{
    private int $totalWords = 0;
    private int $totalImages = 0;
    private float $totalImageSizeKb = 0.0;
    private int $totalTables = 0;
    private int $totalTableRows = 0;
    private int $totalCodeLines = 0;

    public function visitParagraph(Paragraph $paragraph): void
    {
        $this->totalWords += $paragraph->wordCount;
    }

    public function visitImage(Image $image): void
    {
        $this->totalImages++;
        $this->totalImageSizeKb += $image->getSizeKb();
    }

    public function visitTable(Table $table): void
    {
        $this->totalTables++;
        $this->totalTableRows += $table->getRowCount();
    }

    public function visitCodeBlock(CodeBlock $codeBlock): void
    {
        $this->totalCodeLines += $codeBlock->lineCount;
    }

    /** @return array<string, int|float> */
    public function getReport(): array
    {
        return [
            'total_words' => $this->totalWords,
            'total_images' => $this->totalImages,
            'image_size_kb' => round($this->totalImageSizeKb, 1),
            'total_tables' => $this->totalTables,
            'total_table_rows' => $this->totalTableRows,
            'total_code_lines' => $this->totalCodeLines,
        ];
    }
}

// Document contains mixed nodes
final class Document
{
    /** @var list<DocumentNode> */
    private array $nodes = [];

    public function add(DocumentNode $node): void
    {
        $this->nodes[] = $node;
    }

    // Apply any visitor without changing node classes
    public function accept(DocumentVisitor $visitor): void
    {
        foreach ($this->nodes as $node) {
            $node->accept($visitor);
        }
    }
}

// Usage: add operations without touching element classes
$doc = new Document();
$doc->add(new Paragraph('Introduction to design patterns in PHP.', 6));
$doc->add(new Image('/img/uml.png', 'UML Diagram', 800, 600));
$doc->add(new Table(
    headers: ['Pattern', 'Type', 'Complexity'],
    rows: [
        ['Visitor', 'Behavioral', 'High'],
        ['Strategy', 'Behavioral', 'Low'],
    ],
));
$doc->add(new CodeBlock('echo "Hello";', 'php', 1));
$doc->add(new Paragraph('Visitor lets you add operations to objects.', 8));

// Export to HTML
$htmlVisitor = new HtmlExportVisitor();
$doc->accept($htmlVisitor);
echo $htmlVisitor->getResult();

// Collect statistics -- no changes to document nodes!
$statsVisitor = new StatisticsVisitor();
$doc->accept($statsVisitor);
print_r($statsVisitor->getReport());
// ['total_words' => 14, 'total_images' => 1, ...]

Когда использовать

  • Нужно выполнять операции над объектами сложной структуры (дерево, граф)
  • Новые операции добавляются часто, а набор типов элементов стабилен
  • Операция должна работать с объектами разных классов по-разному
  • Хотите вынести логику обработки из классов элементов

В Symfony

<?php
declare(strict_types=1);

// Symfony Serializer: Normalizers act as Visitors
// Each normalizer "visits" a specific object type and transforms it

use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

final readonly class MoneyNormalizer implements NormalizerInterface
{
    /** @param Money $data */
    public function normalize(
        mixed $data,
        ?string $format = null,
        array $context = [],
    ): array {
        return [
            'amount' => $data->getAmount(),
            'currency' => $data->getCurrency()->value,
        ];
    }

    public function supportsNormalization(
        mixed $data,
        ?string $format = null,
        array $context = [],
    ): bool {
        return $data instanceof Money;
    }

    public function getSupportedTypes(?string $format): array
    {
        return [Money::class => true];
    }
}

// Twig NodeVisitor -- classic Visitor over AST
// use Twig\NodeVisitor\NodeVisitorInterface;
// use Twig\Node\Node;
// use Twig\Environment;
//
// final class SecurityNodeVisitor implements NodeVisitorInterface
// {
//     public function enterNode(Node $node, Environment $env): Node
//     {
//         // Inspect/transform node without modifying node classes
//         return $node;
//     }
//
//     public function leaveNode(Node $node, Environment $env): ?Node
//     {
//         return $node;
//     }
//
//     public function getPriority(): int { return 0; }
// }

Сравнение поведенческих паттернов

Паттерн Ключевая идея Symfony-аналог
Strategy Сменные алгоритмы Security Voters
Observer Уведомления об событиях EventDispatcher
Command Запрос как объект Messenger
Chain of Resp. Цепочка обработчиков Middleware, HttpKernel
Template Method Скелет алгоритма AbstractController
State Поведение по состоянию Workflow component
Iterator Обход коллекции Finder
Mediator Централизация взаимодействия EventDispatcher
Memento Сохранение/восстановление состояния Form data mapping
Visitor Добавление операций без изменения классов Serializer Normalizer

Проверь себя

5 из 8

Что такое Memento в контексте паттерна?

Какой компонент Symfony реализует паттерн State Machine?

Когда предпочтительнее использовать Visitor, а не простое добавление методов в классы?

Какой паттерн лучше использовать для HTTP middleware?

Для чего в паттерне Command нужен метод undo()?