Современные паттерны в PHP
Эти паттерны не входят в классическую книгу GoF, но стали стандартом в современных PHP-приложениях на Symfony и Laravel.
Data Transfer Object (DTO)
Проблема
Данные передаются между слоями приложения через массивы -- нет типобезопасности, автодополнения, валидации. Массив $data['usre_name'] (опечатка) -- ошибка обнаружится только в рантайме.
Решение
<?php
declare(strict_types=1);
// Immutable DTO with PHP 8.4 features
final readonly class CreateUserDto
{
public function __construct(
public string $name,
public string $email,
public string $password,
public UserRole $role = UserRole::User,
) {}
// Named constructor from request data
public static function fromRequest(array $data): self
{
return new self(
name: $data['name'] ?? throw new \InvalidArgumentException('Name required'),
email: $data['email'] ?? throw new \InvalidArgumentException('Email required'),
password: $data['password'] ?? throw new \InvalidArgumentException('Password required'),
role: isset($data['role']) ? UserRole::from($data['role']) : UserRole::User,
);
}
}
final readonly class UserResponseDto
{
public function __construct(
public int $id,
public string $name,
public string $email,
public UserRole $role,
public \DateTimeImmutable $createdAt,
) {}
// Build from entity
public static function fromEntity(User $user): self
{
return new self(
id: $user->getId(),
name: $user->getName(),
email: $user->getEmail(),
role: $user->getRole(),
createdAt: $user->getCreatedAt(),
);
}
public function toArray(): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->email,
'role' => $this->role->value,
'created_at' => $this->createdAt->format(\DateTimeInterface::ATOM),
];
}
}
enum UserRole: string
{
case Admin = 'admin';
case User = 'user';
case Moderator = 'moderator';
public function hasPermission(string $permission): bool
{
$permissions = match ($this) {
self::Admin => ['read', 'write', 'delete', 'manage_users'],
self::Moderator => ['read', 'write', 'delete'],
self::User => ['read', 'write'],
};
return in_array($permission, $permissions, true);
}
}
PHP 8.4: DTO с property hooks
<?php
declare(strict_types=1);
// DTO with validation via property hooks
final readonly class CreateOrderDto
{
public string $email {
set(string $value) {
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: {$value}");
}
$this->email = strtolower($value);
}
}
public int $quantity {
set(int $value) {
if ($value < 1 || $value > 1000) {
throw new \InvalidArgumentException('Quantity must be 1-1000');
}
$this->quantity = $value;
}
}
public function __construct(
public int $productId,
string $email,
int $quantity,
public ?string $comment = null,
) {
$this->email = $email;
$this->quantity = $quantity;
}
}
// Validation happens at construction time
$dto = new CreateOrderDto(
productId: 42,
email: '[email protected]', // Normalized to lowercase
quantity: 5,
);
echo $dto->email; // "[email protected]"
Когда использовать
- Передача данных между слоями (Controller -> Service -> Repository)
- API request/response объекты
- Замена ассоциативных массивов типобезопасными объектами
В Symfony
<?php
declare(strict_types=1);
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Serializer\Attribute\SerializedName;
// Symfony DTO with validation attributes
final readonly class RegistrationDto
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(min: 2, max: 100)]
public string $name,
#[Assert\NotBlank]
#[Assert\Email]
public string $email,
#[Assert\NotBlank]
#[Assert\Length(min: 8)]
#[Assert\PasswordStrength]
public string $password,
#[SerializedName('phone_number')]
#[Assert\Regex('/^\+\d{10,15}$/')]
public ?string $phone = null,
) {}
}
// In controller: automatic deserialization + validation
// #[MapRequestPayload] RegistrationDto $dto
Value Object
Проблема
Примитивные типы (string, int, float) не несут бизнес-смысла. string $email может содержать что угодно. Невалидные данные проникают в домен.
Решение
<?php
declare(strict_types=1);
// Value Object: immutable, compared by value, self-validating
final readonly class Email
{
public string $value;
public function __construct(string $value)
{
$normalized = strtolower(trim($value));
if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException("Invalid email: {$value}");
}
$this->value = $normalized;
}
public function domain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
// Money Value Object -- prevents currency mixing bugs
final readonly class Money
{
public function __construct(
public int $amount, // Always in smallest unit (cents)
public Currency $currency,
) {}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \LogicException(
"Cannot add {$this->currency->value} and {$other->currency->value}",
);
}
return new self($this->amount + $other->amount, $this->currency);
}
public function subtract(self $other): self
{
if ($this->currency !== $other->currency) {
throw new \LogicException('Currency mismatch');
}
return new self($this->amount - $other->amount, $this->currency);
}
public function multiply(int $factor): self
{
return new self($this->amount * $factor, $this->currency);
}
public function isPositive(): bool
{
return $this->amount > 0;
}
public function format(): string
{
$decimal = number_format($this->amount / 100, 2, '.', ',');
return "{$this->currency->symbol()}{$decimal}";
}
public function equals(self $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
}
enum Currency: string
{
case USD = 'USD';
case EUR = 'EUR';
case RUB = 'RUB';
public function symbol(): string
{
return match ($this) {
self::USD => '$',
self::EUR => "\u{20AC}",
self::RUB => "\u{20BD}",
};
}
}
// Address Value Object
final readonly class Address
{
public function __construct(
public string $street,
public string $city,
public string $postalCode,
public string $country,
) {
if (trim($street) === '' || trim($city) === '') {
throw new \InvalidArgumentException('Street and city are required');
}
}
public function oneLine(): string
{
return "{$this->street}, {$this->city}, {$this->postalCode}, {$this->country}";
}
public function equals(self $other): bool
{
return $this->street === $other->street
&& $this->city === $other->city
&& $this->postalCode === $other->postalCode
&& $this->country === $other->country;
}
}
// Usage: impossible to create invalid data
$email = new Email('[email protected]');
echo $email->value; // "[email protected]"
$price = new Money(1999, Currency::USD);
$tax = new Money(200, Currency::USD);
$total = $price->add($tax);
echo $total->format(); // "$21.99"
Когда использовать
- Примитив несёт бизнес-смысл (email, money, phone, address)
- Нужна самовалидация при создании
- Сравнение по значению, а не по ссылке
- Иммутабельность -- гарантия целостности
В Symfony / Doctrine
<?php
declare(strict_types=1);
use Doctrine\ORM\Mapping as ORM;
// Doctrine embeddable for Value Objects
#[ORM\Embeddable]
final readonly class Money
{
public function __construct(
#[ORM\Column(type: 'integer')]
public int $amount,
#[ORM\Column(type: 'string', length: 3, enumType: Currency::class)]
public Currency $currency,
) {}
}
// In entity
#[ORM\Entity]
final class Product
{
#[ORM\Embedded(class: Money::class)]
private Money $price;
}
Repository
Проблема
Доменный слой не должен знать о базе данных. Прямые запросы в сервисах делают код нетестируемым и привязанным к ORM.
Решение
<?php
declare(strict_types=1);
// Repository interface in Domain layer
interface OrderRepository
{
public function findById(OrderId $id): ?Order;
public function findByCustomer(CustomerId $customerId): array;
public function save(Order $order): void;
public function remove(Order $order): void;
public function nextId(): OrderId;
}
// Value Object for ID
final readonly class OrderId
{
public function __construct(
public string $value,
) {
if (trim($value) === '') {
throw new \InvalidArgumentException('OrderId cannot be empty');
}
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
final readonly class CustomerId
{
public function __construct(
public string $value,
) {}
}
// Infrastructure implementation with Doctrine
final class DoctrineOrderRepository implements OrderRepository
{
public function __construct(
private readonly EntityManagerInterface $em,
) {}
public function findById(OrderId $id): ?Order
{
return $this->em->find(Order::class, $id->value);
}
public function findByCustomer(CustomerId $customerId): array
{
return $this->em->createQueryBuilder()
->select('o')
->from(Order::class, 'o')
->where('o.customerId = :customerId')
->setParameter('customerId', $customerId->value)
->orderBy('o.createdAt', 'DESC')
->getQuery()
->getResult();
}
public function save(Order $order): void
{
$this->em->persist($order);
$this->em->flush();
}
public function remove(Order $order): void
{
$this->em->remove($order);
$this->em->flush();
}
public function nextId(): OrderId
{
return new OrderId(\Symfony\Component\Uid\Uuid::v7()->toRfc4122());
}
}
// In-memory implementation for tests
final class InMemoryOrderRepository implements OrderRepository
{
/** @var array<string, Order> */
private array $orders = [];
public function findById(OrderId $id): ?Order
{
return $this->orders[$id->value] ?? null;
}
public function findByCustomer(CustomerId $customerId): array
{
return array_filter(
$this->orders,
fn(Order $o): bool => $o->getCustomerId()->equals($customerId),
);
}
public function save(Order $order): void
{
$this->orders[$order->getId()->value] = $order;
}
public function remove(Order $order): void
{
unset($this->orders[$order->getId()->value]);
}
public function nextId(): OrderId
{
return new OrderId(uniqid('order_', true));
}
}
// Service depends on interface -- testable and flexible
final readonly class OrderService
{
public function __construct(
private OrderRepository $orders,
) {}
public function getOrder(string $id): ?Order
{
return $this->orders->findById(new OrderId($id));
}
}
Когда использовать
- Разделение домена и инфраструктуры
- Нужны разные хранилища (БД, кеш, файлы, API)
- Тестирование без базы данных (in-memory реализация)
В Symfony
<?php
declare(strict_types=1);
// Symfony: auto-register repository implementation
// services.yaml
// App\Domain\Repository\OrderRepository:
// class: App\Infrastructure\Repository\DoctrineOrderRepository
// Or via interface binding
// _defaults:
// bind:
// App\Domain\Repository\OrderRepository: '@App\Infrastructure\Repository\DoctrineOrderRepository'
Specification
Проблема
Сложные бизнес-правила дублируются: в контроллере для валидации, в репозитории для фильтрации, в сервисе для авторизации. Правила должны быть выражены один раз как объекты.
Решение
<?php
declare(strict_types=1);
// Generic Specification interface
interface Specification
{
public function isSatisfiedBy(mixed $candidate): bool;
}
// Base class with combinators
abstract class CompositeSpecification implements Specification
{
public function and(Specification $other): Specification
{
return new AndSpecification($this, $other);
}
public function or(Specification $other): Specification
{
return new OrSpecification($this, $other);
}
public function not(): Specification
{
return new NotSpecification($this);
}
}
final readonly class AndSpecification extends CompositeSpecification
{
public function __construct(
private Specification $left,
private Specification $right,
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
return $this->left->isSatisfiedBy($candidate)
&& $this->right->isSatisfiedBy($candidate);
}
}
final readonly class OrSpecification extends CompositeSpecification
{
public function __construct(
private Specification $left,
private Specification $right,
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
return $this->left->isSatisfiedBy($candidate)
|| $this->right->isSatisfiedBy($candidate);
}
}
final readonly class NotSpecification extends CompositeSpecification
{
public function __construct(
private Specification $inner,
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
return !$this->inner->isSatisfiedBy($candidate);
}
}
// Business specifications
final class IsActiveUser extends CompositeSpecification
{
public function isSatisfiedBy(mixed $candidate): bool
{
return $candidate instanceof UserEntity && $candidate->isActive();
}
}
final class HasVerifiedEmail extends CompositeSpecification
{
public function isSatisfiedBy(mixed $candidate): bool
{
return $candidate instanceof UserEntity && $candidate->isEmailVerified();
}
}
final readonly class HasMinimumOrders extends CompositeSpecification
{
public function __construct(
private int $minimum,
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
return $candidate instanceof UserEntity
&& $candidate->getOrderCount() >= $this->minimum;
}
}
// Compose complex rules from simple ones
$eligibleForPromotion = (new IsActiveUser())
->and(new HasVerifiedEmail())
->and(new HasMinimumOrders(5));
// Reuse in different contexts
$users = [/* ... */];
$eligible = array_filter($users, $eligibleForPromotion->isSatisfiedBy(...));
PHP 8.4: Specification с first-class callables
<?php
declare(strict_types=1);
// Modern approach: closures as specifications
final readonly class Spec
{
/** @param \Closure(mixed): bool $predicate */
public function __construct(
private \Closure $predicate,
) {}
public function isSatisfiedBy(mixed $candidate): bool
{
return ($this->predicate)($candidate);
}
public function and(self $other): self
{
return new self(
fn(mixed $c): bool => $this->isSatisfiedBy($c) && $other->isSatisfiedBy($c),
);
}
public function or(self $other): self
{
return new self(
fn(mixed $c): bool => $this->isSatisfiedBy($c) || $other->isSatisfiedBy($c),
);
}
public function not(): self
{
return new self(
fn(mixed $c): bool => !$this->isSatisfiedBy($c),
);
}
}
// Concise specification definitions
$isActive = new Spec(fn(UserEntity $u): bool => $u->isActive());
$isVerified = new Spec(fn(UserEntity $u): bool => $u->isEmailVerified());
$hasOrders = new Spec(fn(UserEntity $u): bool => $u->getOrderCount() >= 5);
$canGetDiscount = $isActive->and($isVerified)->and($hasOrders);
Когда использовать
- Сложные бизнес-правила, используемые в нескольких местах
- Правила комбинируются динамически
- Фильтрация коллекций по бизнес-критериям
- Валидация в доменном слое
CQRS (Command Query Responsibility Segregation)
Проблема
Один и тот же сервис/репозиторий обслуживает и чтение, и запись. Оптимизация одного ухудшает другое. Разные требования к консистентности, кешированию, масштабированию.
Решение
<?php
declare(strict_types=1);
// ===== COMMAND SIDE (Write) =====
// Command -- intent to change state
final readonly class PlaceOrderCommand
{
public function __construct(
public string $customerId,
/** @var list<array{productId: int, quantity: int}> */
public array $items,
public string $shippingAddress,
) {}
}
// Command handler -- executes business logic
final readonly class PlaceOrderHandler
{
public function __construct(
private OrderRepository $orders,
private ProductRepository $products,
private EventDispatcherInterface $events,
) {}
public function __invoke(PlaceOrderCommand $command): string
{
// Validate business rules
$orderId = $this->orders->nextId();
$order = Order::create(
id: $orderId,
customerId: new CustomerId($command->customerId),
address: $command->shippingAddress,
);
foreach ($command->items as $item) {
$product = $this->products->findById($item['productId']);
if ($product === null) {
throw new ProductNotFoundException($item['productId']);
}
$order->addItem($product, $item['quantity']);
}
$this->orders->save($order);
// Dispatch domain events
$this->events->dispatch(new OrderPlacedEvent(
orderId: $orderId->value,
customerId: $command->customerId,
total: $order->getTotal()->amount,
));
return $orderId->value;
}
}
// ===== QUERY SIDE (Read) =====
// Query -- request for data (no side effects)
final readonly class GetOrderQuery
{
public function __construct(
public string $orderId,
) {}
}
// Read model -- optimized for display
final readonly class OrderView
{
public function __construct(
public string $id,
public string $customerName,
public string $status,
public string $total,
public string $createdAt,
/** @var list<OrderItemView> */
public array $items,
) {}
}
final readonly class OrderItemView
{
public function __construct(
public string $productName,
public int $quantity,
public string $unitPrice,
public string $subtotal,
) {}
}
// Query handler -- reads from optimized store
final readonly class GetOrderHandler
{
public function __construct(
private \PDO $readDb, // Can be a read replica
) {}
public function __invoke(GetOrderQuery $query): ?OrderView
{
$stmt = $this->readDb->prepare(<<<'SQL'
SELECT o.id, c.name as customer_name, o.status,
o.total_amount, o.created_at
FROM order_views o
JOIN customers c ON c.id = o.customer_id
WHERE o.id = :id
SQL);
$stmt->execute(['id' => $query->orderId]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
if ($row === false) {
return null;
}
return new OrderView(
id: $row['id'],
customerName: $row['customer_name'],
status: $row['status'],
total: $row['total_amount'],
createdAt: $row['created_at'],
items: $this->loadItems($query->orderId),
);
}
private function loadItems(string $orderId): array
{
// Load from denormalized view table
return [];
}
}
// ===== MESSAGE BUS (Dispatcher) =====
interface CommandBus
{
public function dispatch(object $command): mixed;
}
interface QueryBus
{
public function ask(object $query): mixed;
}
// Controller uses separate buses
final readonly class OrderController
{
public function __construct(
private CommandBus $commandBus,
private QueryBus $queryBus,
) {}
public function create(array $requestData): string
{
$orderId = $this->commandBus->dispatch(
new PlaceOrderCommand(
customerId: $requestData['customer_id'],
items: $requestData['items'],
shippingAddress: $requestData['address'],
),
);
return $orderId;
}
public function show(string $id): ?OrderView
{
return $this->queryBus->ask(new GetOrderQuery($id));
}
}
Когда использовать
- Разные требования к чтению и записи (нагрузка, кеш)
- Сложные write-модели и простые read-модели
- Масштабирование чтения отдельно от записи
- Event Sourcing (CQRS -- естественный компаньон)
В Symfony
<?php
declare(strict_types=1);
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\HandleTrait;
use Symfony\Component\Messenger\MessageBusInterface;
// Symfony Messenger as Command/Query Bus
// config/packages/messenger.yaml
// framework:
// messenger:
// buses:
// command.bus: ~
// query.bus: ~
#[AsMessageHandler(bus: 'command.bus')]
final readonly class PlaceOrderSymfonyHandler
{
public function __invoke(PlaceOrderCommand $command): string
{
// Handle command...
return 'order-id';
}
}
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetOrderSymfonyHandler
{
public function __invoke(GetOrderQuery $query): ?OrderView
{
// Return read model...
return null;
}
}
Event-Driven Architecture
Проблема
Компоненты системы тесно связаны. Изменение в одном модуле каскадно ломает другие. Сложно добавлять новую логику на существующие действия.
Решение
<?php
declare(strict_types=1);
// Domain event -- immutable fact that happened
final readonly class OrderPlacedEvent
{
public function __construct(
public string $orderId,
public string $customerId,
public int $totalCents,
public \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),
) {}
}
final readonly class PaymentProcessedEvent
{
public function __construct(
public string $orderId,
public string $transactionId,
public int $amountCents,
public \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),
) {}
}
// Aggregate root that produces events
final class Order
{
/** @var list<object> */
private array $domainEvents = [];
private function __construct(
private readonly OrderId $id,
private readonly CustomerId $customerId,
private OrderStatus $status,
private Money $total,
) {}
public static function place(
OrderId $id,
CustomerId $customerId,
Money $total,
): self {
$order = new self($id, $customerId, OrderStatus::Draft, $total);
// Record domain event
$order->recordEvent(new OrderPlacedEvent(
orderId: $id->value,
customerId: $customerId->value,
totalCents: $total->amount,
));
return $order;
}
public function markPaid(string $transactionId): void
{
if ($this->status !== OrderStatus::Draft) {
throw new \LogicException('Only draft orders can be paid');
}
$this->status = OrderStatus::Confirmed;
$this->recordEvent(new PaymentProcessedEvent(
orderId: $this->id->value,
transactionId: $transactionId,
amountCents: $this->total->amount,
));
}
/** @return list<object> */
public function releaseEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
private function recordEvent(object $event): void
{
$this->domainEvents[] = $event;
}
public function getId(): OrderId { return $this->id; }
public function getCustomerId(): CustomerId { return $this->customerId; }
public function getTotal(): Money { return $this->total; }
}
// Event listeners -- decoupled reactions
final readonly class SendOrderConfirmationEmail
{
public function __construct(
private MailerInterface $mailer,
private CustomerRepository $customers,
) {}
public function __invoke(OrderPlacedEvent $event): void
{
$customer = $this->customers->findById(
new CustomerId($event->customerId),
);
// Send confirmation email
}
}
final readonly class UpdateSalesAnalytics
{
public function __construct(
private AnalyticsService $analytics,
) {}
public function __invoke(OrderPlacedEvent $event): void
{
$this->analytics->recordSale(
orderId: $event->orderId,
amount: $event->totalCents,
date: $event->occurredAt,
);
}
}
final readonly class ReserveInventory
{
public function __invoke(OrderPlacedEvent $event): void
{
// Reserve stock for order items
}
}
// Dispatch events after persisting aggregate
final readonly class EventDispatchingOrderRepository implements OrderRepository
{
public function __construct(
private OrderRepository $inner,
private EventDispatcherInterface $events,
) {}
public function save(Order $order): void
{
$this->inner->save($order);
// Dispatch events AFTER successful persistence
foreach ($order->releaseEvents() as $event) {
$this->events->dispatch($event);
}
}
// ... delegate other methods
}
Когда использовать
- Слабое связывание между модулями
- Несколько реакций на одно действие
- Асинхронная обработка (через message queue)
- Аудит и логирование бизнес-событий
В Symfony
<?php
declare(strict_types=1);
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
// Sync: EventDispatcher
#[AsEventListener(event: OrderPlacedEvent::class)]
final readonly class LogOrderListener
{
public function __invoke(OrderPlacedEvent $event): void
{
// Synchronous logging
}
}
// Async: Messenger (event goes to RabbitMQ/Redis)
#[AsMessageHandler]
final readonly class SendEmailAsync
{
public function __invoke(OrderPlacedEvent $event): void
{
// Processed asynchronously by worker
}
}
// Routing in messenger.yaml:
// framework:
// messenger:
// routing:
// App\Event\OrderPlacedEvent: async
Сравнение современных паттернов
| Паттерн | Ключевая идея | Слой |
|---|---|---|
| DTO | Типобезопасная передача данных | Application |
| Value Object | Самовалидирующиеся доменные значения | Domain |
| Repository | Абстракция хранилища | Domain + Infrastructure |
| Specification | Бизнес-правила как объекты | Domain |
| CQRS | Разделение чтения и записи | Architecture |
| Event-Driven | Реакция на факты вместо вызовов | Architecture |