Your Code is a Fortress, Not Spaghetti
\n\nHave you ever rewritten a project from Laravel to Symfony or from MySQL to PostgreSQL? If so, you know the pain: controllers soaked in SQL queries, models that know everything about HTTP requests, and business logic that dies when you change the framework. Sound familiar?
\n\nWelcome to the world of Hexagonal Architecture in PHP (also known as Ports & Adapters). It's not a trendy buzzword, but a lifeline for projects that want to live long and prosper. In this article, we'll break down how to build a PHP application that isn't afraid of change: switching databases, moving to a different cache, or a new API — all of this becomes a routine adapter swap, not a rewrite of half the code.
\n\nWhat is Hexagonal Architecture Really?
\n\nImagine your application is a core that doesn't know where data comes from: the web, a console, a Telegram bot, or tests. Around this core are ports (interfaces), and on the outside are adapters (implementations). Hexagonal architecture in PHP allows you to separate business logic from infrastructure code. You write the rules once and use them everywhere.
\n\nKey principles:
\n- \n
- Dependency Inversion — high-level code does not depend on low-level code. It's the other way around. \n
- Business Logic is Isolated — no framework penetrates your Use Cases. \n
- Testability — you can test the core without a database or HTTP. \n
Unlike Robert Martin's Clean Architecture, Hexagonal is more flexible: it doesn't dictate the number of layers, but simply says: "Inside is the domain, outside is infrastructure, connect them through interfaces."
\n\nProject Structure: From Chaos to Order
\n\nLet's get straight to the point. Here is a typical folder structure for Hexagonal architecture in PHP:
\n\nsrc/\n├── Domain/\n│ ├── Entity/\n│ │ └── Order.php\n│ ├── Repository/\n│ │ └── OrderRepositoryInterface.php\n│ └── Service/\n│ └── OrderService.php\n├── Application/\n│ ├── UseCase/\n│ │ └── CreateOrderUseCase.php\n│ └── Port/\n│ ├── Input/\n│ │ └── CreateOrderInputPort.php\n│ └── Output/\n│ └── OrderOutputPort.php\n├── Infrastructure/\n│ ├── Persistence/\n│ │ └── DoctrineOrderRepository.php\n│ └── Http/\n│ └── OrderController.php\n└── Framework/\n └── Symfony/\n └── Kernel.php\n\nNotice? Domain and Application know nothing about Doctrine, Symfony, or MySQL. They only work with interfaces. Adapters live in Infrastructure and are connected via Dependency Injection.
Step 1: Define the Core — Domain
\n\nLet's start with an order entity. No ORM annotations, no setters that break invariants:
\n\n<?php\n// src/Domain/Entity/Order.php\nnamespace App\\Domain\\Entity;\n\nclass Order\n{\n public function __construct(\n private readonly string $id,\n private readonly string $productName,\n private float $totalAmount,\n private string $status = 'pending'\n ) {}\n\n public function approve(): void\n {\n if ($this->status !== 'pending') {\n throw new \\DomainException('Only pending orders can be approved.');\n }\n $this->status = 'approved';\n }\n\n public function getId(): string { return $this->id; }\n public function getProductName(): string { return $this->productName; }\n public function getTotalAmount(): float { return $this->totalAmount; }\n public function getStatus(): string { return $this->status; }\n}\n\nAnd here is the port (interface) for the repository. Note: it knows nothing about Doctrine or PDO:
\n\n<?php\n// src/Domain/Repository/OrderRepositoryInterface.php\nnamespace App\\Domain\\Repository;\n\nuse App\\