Hexagonal архитектура в PHP: чистота кода без боли

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Ваш код — это крепость, а не лапша

Вы когда-нибудь переписывали проект с 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                

Блоги

Рекомендаций по книгам