Ваш код — это крепость, а не лапша
Вы когда-нибудь переписывали проект с Laravel на Symfony или с MySQL на PostgreSQL? Если да, то вы знаете эту боль: контроллеры, пропитанные SQL-запросами, модели, которые знают всё о HTTP-запросах, и бизнес-логика, которая умирает при смене фреймворка. Знакомо?
Добро пожаловать в мир Hexagonal архитектуры в PHP (она же Ports & Adapters). Это не модный buzzword, а спасательный круг для проектов, которые хотят жить долго и счастливо. В этой статье мы разберём, как построить PHP-приложение, которое не боится перемен: смена базы данных, переход на другой кэш или новый API — всё это станет рутинной заменой адаптера, а не переписыванием половины кода.
Что такое Hexagonal архитектура на самом деле?
Представьте, что ваше приложение — это ядро (core), которое не знает, откуда приходят данные: из веба, консоли, Telegram-бота или тестов. Вокруг этого ядра — порты (интерфейсы), а снаружи — адаптеры (реализации). Гексагональная архитектура в PHP позволяет отделить бизнес-логику от инфраструктурного кода. Вы пишете правила один раз и используете их везде.
Ключевые принципы:
- Инверсия зависимостей — код верхнего уровня не зависит от нижнего. Всё наоборот.
- Бизнес-логика изолирована — ни один фреймворк не проникает в ваши Use Cases.
- Тестируемость — вы можете протестировать ядро без базы данных и HTTP.
В отличие от Clean Architecture Роберта Мартина, Hexagonal более гибкая: она не диктует количество слоёв, а просто говорит: «Внутри — домен, снаружи — инфраструктура, соединяйте их через интерфейсы».
Структура проекта: от хаоса к порядку
Давайте сразу перейдём к делу. Вот типичная структура папок для Hexagonal архитектуры в PHP:
src/
├── Domain/
│ ├── Entity/
│ │ └── Order.php
│ ├── Repository/
│ │ └── OrderRepositoryInterface.php
│ └── Service/
│ └── OrderService.php
├── Application/
│ ├── UseCase/
│ │ └── CreateOrderUseCase.php
│ └── Port/
│ ├── Input/
│ │ └── CreateOrderInputPort.php
│ └── Output/
│ └── OrderOutputPort.php
├── Infrastructure/
│ ├── Persistence/
│ │ └── DoctrineOrderRepository.php
│ └── Http/
│ └── OrderController.php
└── Framework/
└── Symfony/
└── Kernel.php
Заметили? Domain и Application не знают о существовании Doctrine, Symfony или MySQL. Они работают только с интерфейсами. Адаптеры живут в Infrastructure и подключаются через Dependency Injection.
Шаг 1: Определяем ядро — Domain
Начнём с сущности заказа. Никаких аннотаций ORM, никаких сеттеров, которые нарушают инварианты:
<?php
// src/Domain/Entity/Order.php
namespace App\Domain\Entity;
class Order
{
public function __construct(
private readonly string $id,
private readonly string $productName,
private float $totalAmount,
private string $status = 'pending'
) {}
public function approve(): void
{
if ($this->status !== 'pending') {
throw new \DomainException('Only pending orders can be approved.');
}
$this->status = 'approved';
}
public function getId(): string { return $this->id; }
public function getProductName(): string { return $this->productName; }
public function getTotalAmount(): float { return $this->totalAmount; }
public function getStatus(): string { return $this->status; }
}
А вот порт (интерфейс) для репозитория. Обратите внимание: он ничего не знает о Doctrine или PDO:
<?php
// src/Domain/Repository/OrderRepositoryInterface.php
namespace App\Domain\Repository;
use App\Domain\Entity\Order;
interface OrderRepositoryInterface
{
public function save(Order $order): void;
public function findById(string $id): ?Order;
}
Шаг 2: Use Case — сердце приложения
Теперь создадим сценарий использования. Он принимает входные данные через порт, вызывает доменный сервис и возвращает результат через другой порт. Это чистая архитектура PHP в действии:
<?php
// src/Application/UseCase/CreateOrderUseCase.php
namespace App\Application\UseCase;
use App\Domain\Entity\Order;
use App\Domain\Repository\OrderRepositoryInterface;
use App\Application\Port\Input\CreateOrderInputPort;
use App\Application\Port\Output\OrderOutputPort;
class CreateOrderUseCase
{
public function __construct(
private OrderRepositoryInterface $orderRepository,
private OrderOutputPort $outputPort
) {}
public function execute(CreateOrderInputPort $input): void
{
$order = new Order(
uniqid(),
$input->getProductName(),
$input->getTotalAmount()
);
// Здесь может быть доменная логика: проверка лимитов, скидки и т.д.
$this->orderRepository->save($order);
$this->outputPort->present($order);
}
}
А вот сами порты:
<?php
// src/Application/Port/Input/CreateOrderInputPort.php
namespace App\Application\Port\Input;
interface CreateOrderInputPort
{
public function getProductName(): string;
public function getTotalAmount(): float;
}
// src/Application/Port/Output/OrderOutputPort.php
namespace App\Application\Port\Output;
use App\Domain\Entity\Order;
interface OrderOutputPort
{
public function present(Order $order): void;
}
Адаптеры: подключаем реальный мир
Теперь самое интересное — реализация. Вот адаптер для Doctrine ORM:
<?php
// src/Infrastructure/Persistence/DoctrineOrderRepository.php
namespace App\Infrastructure\Persistence;
use App\Domain\Entity\Order;
use App\Domain\Repository\OrderRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
class DoctrineOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private EntityManagerInterface $entityManager
) {}
public function save(Order $order): void
{
$this->entityManager->persist($order);
$this->entityManager->flush();
}
public function findById(string $id): ?Order
{
return $this->entityManager->find(Order::class, $id);
}
}
А вот адаптер для контроллера Symfony, который подключается к Use Case через порты:
<?php
// src/Infrastructure/Http/OrderController.php
namespace App\Infrastructure\Http;
use App\Application\UseCase\CreateOrderUseCase;
use App\Application\Port\Input\CreateOrderInputPort;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
class OrderController
{
public function __construct(
private CreateOrderUseCase $createOrderUseCase
) {}
public function create(Request $request): JsonResponse
{
$input = new class($request->toArray