Поведенческие паттерны (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 |