Когда ваш PHP-монолит начинает трещать по швам
Вы когда-нибудь оказывались в ситуации, когда смена базы данных с MySQL на PostgreSQL превращалась в недельный квест? Или когда нужно было добавить новый API-клиент, а код был так переплетён с фреймворком, что проще было написать всё с нуля? Знакомо?
Большинство PHP-разработчиков рано или поздно сталкиваются с «лапшой» в контроллерах и моделях. Фреймворки вроде Laravel или Symfony дают удобные инструменты, но они же и навязывают жёсткую связь с собой. Выход есть — Hexagonal архитектура (она же «порты и адаптеры», она же гексагональная архитектура).
В этой статье мы разберём, как построить приложение на PHP, которое не зависит от фреймворка, базы данных или внешних сервисов. Вы увидите примеры кода, которые можно сразу применить в своём проекте. Поехали!
Что такое Hexagonal архитектура и зачем она вам?
Представьте себе шестиугольник. В центре — ваша бизнес-логика (домен). Вокруг — «порты» (интерфейсы), через которые внешний мир общается с ядром. А снаружи — «адаптеры», которые реализуют эти порты: контроллеры, репозитории, API-клиенты.
Главная идея: ядро приложения ничего не знает о внешнем мире. Оно не импортирует Laravel, не знает про PDO и не вызывает curl. Вся зависимость направлена внутрь — адаптеры зависят от портов, а не наоборот.
Плюсы такого подхода очевидны:
- Тестируемость: бизнес-логику можно тестировать без базы данных и HTTP-запросов.
- Гибкость: замена БД, очереди или фреймворка — это просто написание нового адаптера.
- Чистота кода: вы не смешиваете «что делать» с «как делать».
Звучит как магия? Давайте посмотрим на код.
Структура проекта: базовый скелет
Для примера возьмём простой сценарий: регистрация пользователя. Нам нужно сохранить пользователя в БД и отправить приветственное письмо.
Вот как будет выглядеть типичная структура директорий:
src/
├── Domain/
│ ├── Entity/
│ │ └── User.php
│ ├── Repository/
│ │ └── UserRepositoryInterface.php
│ ├── Service/
│ │ └── UserRegistrationService.php
│ └── Event/
│ └── UserRegisteredEvent.php
├── Infrastructure/
│ ├── Persistence/
│ │ └── DoctrineUserRepository.php
│ ├── Mail/
│ │ └── SymfonyMailerAdapter.php
│ └── Controller/
│ └── RegisterUserController.php
└── Application/
└── UseCase/
└── RegisterUserUseCase.php
Обратите внимание: Domain — это чистая бизнес-логика. Infrastructure — адаптеры. Application — сценарии использования (use cases).
Шаг 1: Ядро — домен и порты
Сущность (Entity)
Начнём с самого простого — пользователя. Это просто класс с данными, без зависимостей.
<?php
namespace App\Domain\Entity;
class User
{
public function __construct(
private readonly string $id,
private string $name,
private string $email
) {}
public function getId(): string
{
return $this->id;
}
public function getName(): string
{
return $this->name;
}
public function getEmail(): string
{
return $this->email;
}
}
Порт — интерфейс репозитория
Теперь определим, как мы будем сохранять пользователя. Это порт — интерфейс, который будет реализован адаптером.
<?php
namespace App\Domain\Repository;
use App\Domain\Entity\User;
interface UserRepositoryInterface
{
public function save(User $user): void;
public function findByEmail(string $email): ?User;
}
Порт — интерфейс почтового сервиса
Аналогично для отправки писем:
<?php
namespace App\Domain\Service;
use App\Domain\Entity\User;
interface MailerInterface
{
public function sendWelcomeEmail(User $user): void;
}
Шаг 2: Use Case — сценарий регистрации
В центре архитектуры находится use case. Он оркестрирует бизнес-логику, используя порты. Обратите внимание: он не знает, какая именно БД или почтовик используется.
<?php
namespace App\Application\UseCase;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use App\Domain\Service\MailerInterface;
class RegisterUserUseCase
{
public function __construct(
private UserRepositoryInterface $userRepository,
private MailerInterface $mailer
) {}
public function execute(string $name, string $email): User
{
// Проверка на дубликат
$existingUser = $this->userRepository->findByEmail($email);
if ($existingUser !== null) {
throw new \InvalidArgumentException('User already exists');
}
// Создаём пользователя
$user = new User(uniqid(), $name, $email);
$this->userRepository->save($user);
// Отправляем письмо
$this->mailer->sendWelcomeEmail($user);
return $user;
}
}
Вот это и есть сердце приложения. Код не зависит от фреймворка, его можно тестировать в изоляции, подменяя репозиторий и почтовик моками.
Шаг 3: Адаптеры — реализация портов
Репозиторий на Doctrine (ORM)
Теперь реализуем интерфейс. В этом примере используем Doctrine ORM, но вы можете использовать Eloquent или даже файловую систему.
<?php
namespace App\Infrastructure\Persistence;
use App\Domain\Entity\User;
use App\Domain\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
class DoctrineUserRepository implements UserRepositoryInterface
{
public function __construct(
private EntityManagerInterface $em
) {}
public function save(User $user): void
{
$this->em->persist($user);
$this->em->flush();
}
public function findByEmail(string $email): ?User
{
return $this->em->getRepository(User::class)->findOneBy(['email' => $email]);
}
}
Почтовый адаптер на Symfony Mailer
<?php
namespace App\Infrastructure\Mail;
use App\Domain\Entity\User;
use App\Domain\Service\MailerInterface;
use Symfony\Component\Mailer\MailerInterface as SymfonyMailer;
use Symfony\Component\Mime\Email;
class SymfonyMailerAdapter implements MailerInterface
{
public function __construct(
private SymfonyMailer $mailer
) {}
public function sendWelcomeEmail(User $user): void
{
$email = (new Email())
->to($user->getEmail())
->subject('Welcome!')
->text