Introduction to Hexagonal Architecture
Have you ever inherited a PHP project where changing one small feature breaks three other seemingly unrelated parts? Or struggled to write tests because everything is tightly coupled to the database? Welcome to the world of architectural chaos!
This guide will show you how to escape that nightmare by implementing Hexagonal Architecture in PHP. We'll use a real-world news aggregator project to demonstrate how this pattern can transform your codebase from a tangled mess into a well-organized, testable, and maintainable application.
No theoretical fluff here β just practical, actionable insights that you can apply immediately. Ready to turn your chaotic codebase into clean architecture? Let's begin!
What is Hexagonal Architecture?
Think of Hexagonal Architecture as building a fortress around your business logic. Also known as Ports and Adapters, this pattern creates a protective barrier that shields your core application from the volatile world of external dependencies.
Imagine your application as a medieval castle. The inner keep (your domain logic) contains all the valuable assets - your business rules, entities, and critical operations. The outer walls (infrastructure layer) handle all the messy interactions with the outside world - databases, APIs, file systems, and user interfaces.
In our news aggregator, this fortress approach delivers concrete benefits:
- Business rules live in isolation - News validation and processing logic remains pure and testable
- External dependencies become pluggable - Swap RSS sources or database engines without breaking anything
- Multiple interfaces, same core - Whether users access via web browser or CLI, they interact with identical business logic
- Testing becomes a breeze - Mock external dependencies and test your core logic in isolation
The Game-Changing Benefits
Still skeptical? Let me show you why Hexagonal Architecture will revolutionize your PHP development experience:
π Fearless Refactoring: Change your database from MySQL to PostgreSQL without breaking a sweat - your business logic doesn't even notice
π§ͺ Lightning-Fast Testing: Write comprehensive unit tests that run in milliseconds, not minutes. No more waiting for database setup in your test suite
π§ Effortless Feature Addition: Adding a new RSS source? Just create a new adapter. Your existing code remains untouched and unbroken
π― Framework Freedom: Not married to Laravel? Great! Switch to Symfony, CodeIgniter, or go completely framework-free - your core logic travels with you
π Business Logic Clarity: Your domain rules become crystal clear, living in their own dedicated space where they belong
The Three Pillars of Hexagonal Architecture
Let's demystify the core concepts that make Hexagonal Architecture so powerful. Think of these as the three pillars supporting your architectural fortress - remove one, and the whole structure crumbles.
Pillar 1: Ports and Adapters - The Gateway System
Picture your application as a bustling medieval city. Ports are the gates in the city wall - they define what can enter and exit, but don't care about the details of who's coming or going. Adapters are the specific gatekeepers - they handle the actual mechanics of opening the gate for merchants, soldiers, or travelers.
Ports (interfaces) declare the contract: "I need to save news articles, but I don't care if you use MySQL, PostgreSQL, or carrier pigeons."
Adapters (implementations) fulfill the contract: "I'll handle saving to MySQL, complete with all the messy SQL details."
Here's a real example from our news aggregator:
// Port: An interface defining news persistence operations
interface NewsRepository
{
public function save(NewsEntity $entity): NewsEntity;
public function findById(int $id): ?NewsEntity;
public function existsByUrl(string $url): bool;
public function findAll(): array;
}
// Adapter: MySQL implementation of the repository
class MysqlNewsRepository extends BaseRepository implements NewsRepository
{
public function save(NewsEntity $entity): NewsEntity
{
if ($entity->getId() === 0) {
return $this->insert($entity);
}
return $this->update($entity);
}
public function findById(int $id): ?NewsEntity
{
$sql = "SELECT * FROM news WHERE id = :id";
$stmt = $this->connection->prepare($sql);
$stmt->execute(['id' => $id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
return $row ? MysqlNewsMapper::toDomain($row) : null;
}
}
In this example, NewsRepository
is the port, and MysqlNewsRepository
is the adapter.
Pillar 2: Dependency Injection - The Plug-and-Play System
Dependency Injection is the secret sauce that makes Hexagonal Architecture truly magical. Instead of your classes creating their own dependencies (which leads to tight coupling), you inject the dependencies from the outside. It's like having a universal power adapter - plug in any device, and it just works.
Here's how our CLI command system uses DI:
// Command registry with factory functions for DI
$commands = [
'extract-news' => function () {
// Dependencies are injected here
$newsRepository = new MysqlNewsRepository();
$parserFactory = new RssParserFactory();
$rssParser = $parserFactory->createCompositeParser();
$extractNewsUseCase = new ExtractNewsUseCase($newsRepository, $rssParser);
return new ExtractNewsCommand($extractNewsUseCase, $rssSources);
}
];
With this setup, you can easily switch from MysqlNewsRepository
to PostgresNewsRepository
or even MongoNewsRepository
without changing any business logic.
Pillar 3: Separation of Concerns - The Organization System
This is where the magic happens. Separation of Concerns is like having a well-organized toolbox where every tool has its place. Your screwdrivers don't mingle with your wrenches, and your business logic doesn't get tangled up with database code. Our news aggregator exemplifies this organization:
src/app/
βββ Core/ # Framework-agnostic utilities
βββ Domain/ # Pure business logic
β βββ News/
β βββ Entities/ # Rich domain models
β βββ Repositories/ # Repository interfaces (ports)
β βββ UseCases/ # Application use cases
β βββ Exceptions/ # Domain-specific exceptions
βββ Infrastructure/ # External adapters
βββ Database/ # Database implementations
βββ Rss/ # RSS parsing implementations
βββ Http/ # Web controllers
βββ Cli/ # CLI commands
Building Your Hexagonal Fortress: A Step-by-Step Guide
Ready to transform your chaotic codebase? Let's build a robust Hexagonal Architecture from the ground up using our news aggregator as the blueprint. No more theoretical talk - it's time to get our hands dirty with real code!
Step 1: Laying the Foundation
Think of this as constructing the blueprint for your fortress. We're building three distinct territories, each with its own purpose and rules:
- Core: The foundation stones - universal utilities that work anywhere, anytime
- Domain: The treasure vault - where your precious business logic lives, protected from external chaos
- Infrastructure: The outer defenses - handling all the messy interactions with databases, APIs, and user interfaces
Your project structure should look like this:
src/
βββ app/
β βββ Core/
β β βββ Entities/
β β βββ Http/
β β βββ Helpers/
β βββ Domain/
β β βββ News/
β β βββ Entities/
β β βββ Repositories/
β β βββ UseCases/
β β βββ Services/
β β βββ Exceptions/
β βββ Infrastructure/
β βββ Database/
β βββ Rss/
β βββ Http/
β βββ Cli/
βββ config/
Step 2: Creating the Gates (Defining Ports)
Now we're defining the entry points to our domain. These interfaces are like customs checkpoints - they specify exactly what can pass through, but don't care about the implementation details:
// Domain/News/Repositories/NewsRepository.php
interface NewsRepository
{
public function save(NewsEntity $entity): NewsEntity;
public function findById(int $id): ?NewsEntity;
public function delete(int $id): bool;
public function findAll(): array;
public function search(string $searchTerm, int $page = 1, int $limit = 15): array;
public function existsByUrl(string $url): bool;
}
// Domain/News/Services/RssParserInterface.php
interface RssParserInterface
{
public function parseFromUrl(string $url): array;
public function canHandle(string $url): bool;
}
Step 3: Building the Gatekeepers (Implementing Adapters)
Time to create the concrete implementations that do the actual work. These adapters are like skilled craftsmen who know exactly how to fulfill each contract:
// Infrastructure/Database/Repositories/MysqlNewsRepository.php
class MysqlNewsRepository extends BaseRepository implements NewsRepository
{
public function save(NewsEntity $entity): NewsEntity
{
if ($entity->getId() === 0) {
return $this->insert($entity);
}
return $this->update($entity);
}
public function existsByUrl(string $url): bool
{
$sql = "SELECT COUNT(*) FROM news WHERE url = :url";
$stmt = $this->connection->prepare($sql);
$stmt->execute(['url' => $url]);
return (int) $stmt->fetchColumn() > 0;
}
}
// Infrastructure/Rss/Parsers/JetBrainsRssParser.php
class JetBrainsRssParser extends AbstractRssParser
{
public function canHandle(string $url): bool
{
return str_contains($url, 'jetbrains.com');
}
protected function extractSource(SimpleXMLElement $item): ?string
{
return 'JetBrains';
}
}
Structuring Your PHP Project for Hexagonal Architecture
By now, you should have a clear structure in place. Here's what your complete project structure should look like:
/news-aggregator
βββ src/
β βββ app/
β β βββ Core/
β β β βββ Entities/Entity.php
β β β βββ Http/
β β β βββ Router.php
β β βββ Domain/
β β β βββ News/
β β β βββ Entities/NewsEntity.php
β β β βββ Repositories/NewsRepository.php
β β β βββ UseCases/
β β β β βββ ExtractNewsUseCase.php
β β β β βββ ListNewsUseCase.php
β β β βββ Exceptions/
β β βββ Infrastructure/
β β βββ Database/
β β β βββ Repositories/MysqlNewsRepository.php
β β β βββ Mappers/MysqlNewsMapper.php
β β βββ Rss/
β β β βββ Parsers/
β β β βββ Factories/RssParserFactory.php
β β βββ Http/Controllers/
β β βββ Cli/Commands/
β βββ config/
β βββ tests/
βββ docker-compose.yml
βββ composer.json
This structure keeps everything neat and organized, making it easy to navigate and maintain.
Putting It All Together: Building Our News Aggregator
Enough theory! Let's roll up our sleeves and build a real news aggregator that showcases every aspect of Hexagonal Architecture. You'll see how each piece fits together like a perfectly crafted puzzle.
The Complete Implementation Walkthrough
Step 1: Define the Domain Entity
// Domain/News/Entities/NewsEntity.php
class NewsEntity
{
private const MAX_TITLE_LENGTH = 255;
private const MAX_URL_LENGTH = 255;
private const VALID_STATUSES = ['draft', 'published', 'archived'];
private int $id;
private string $title;
private string $url;
private ?string $description;
private ?string $author;
private ?DateTimeInterface $publishedDate;
private string $status;
public function __construct(
string $title,
string $url,
int $id = 0,
?string $description = null,
?string $author = null,
?DateTimeInterface $publishedDate = null,
string $status = 'draft'
) {
$this->setTitle($title);
$this->setUrl($url);
$this->setId($id);
$this->description = $description;
$this->author = $author;
$this->publishedDate = $publishedDate;
$this->setStatus($status);
}
public function setTitle(string $title): void
{
$trimmedTitle = trim($title);
if (empty($trimmedTitle)) {
throw InvalidNewsDataException::emptyTitle();
}
if (strlen($trimmedTitle) > self::MAX_TITLE_LENGTH) {
throw InvalidNewsDataException::titleTooLong(self::MAX_TITLE_LENGTH);
}
$this->title = $trimmedTitle;
}
public function setUrl(string $url): void
{
$trimmedUrl = trim($url);
if (empty($trimmedUrl)) {
throw InvalidNewsDataException::emptyUrl();
}
if (!filter_var($trimmedUrl, FILTER_VALIDATE_URL)) {
throw InvalidNewsDataException::invalidUrlFormat();
}
$this->url = $trimmedUrl;
}
// ... other getters and setters
}
Step 2: Create Use Cases in Domain Layer
// Domain/News/UseCases/ExtractNewsUseCase.php
class ExtractNewsUseCase
{
private NewsRepository $newsRepository;
private RssParserInterface $rssParser;
public function __construct(
NewsRepository $newsRepository,
RssParserInterface $rssParser
) {
$this->newsRepository = $newsRepository;
$this->rssParser = $rssParser;
}
public function execute(string $rssUrl): int
{
try {
echo "Fetching RSS feed from: {$rssUrl}\n";
// Parse RSS feed using the injected parser
$newsItems = $this->rssParser->parseFromUrl($rssUrl);
$savedCount = 0;
foreach ($newsItems as $newsItem) {
// Check for duplicates
if ($this->newsRepository->existsByUrl($newsItem->getUrl())) {
continue;
}
// Save news item
$this->newsRepository->save($newsItem);
$savedCount++;
}
return $savedCount;
} catch (Exception $e) {
throw new InvalidNewsDataException("Failed to extract news: " . $e->getMessage());
}
}
}
Step 3: Implement Infrastructure Adapters
// Infrastructure/Rss/Parsers/AbstractRssParser.php
abstract class AbstractRssParser implements RssParserInterface
{
public function parseFromUrl(string $url): array
{
$xml = $this->fetchRssContent($url);
return $this->parseRssXml($xml);
}
protected function createNewsEntityFromItem(SimpleXMLElement $item): ?NewsEntity
{
try {
$title = $this->extractTitle($item);
$url = $this->extractUrl($item);
$description = $this->extractDescription($item);
$author = $this->extractAuthor($item);
$pubDate = $this->extractDate($item);
$source = $this->extractSource($item);
if (empty($title) || empty($url)) {
return null;
}
return new NewsEntity(
title: $title,
url: $url,
description: $description,
author: $author,
publishedDate: $pubDate,
status: 'published'
);
} catch (Exception $e) {
return null;
}
}
abstract protected function extractSource(SimpleXMLElement $item): ?string;
// ... other extraction methods
}
Step 4: Create Factory for Parser Selection
// Infrastructure/Rss/Factories/RssParserFactory.php
class RssParserFactory
{
private array $parsers;
public function __construct()
{
$this->parsers = [
new JetBrainsRssParser(),
new RedditRssParser(),
new StackOverflowRssParser(),
new PhpClassesRssParser()
];
}
public function createParser(string $url): RssParserInterface
{
foreach ($this->parsers as $parser) {
if ($parser->canHandle($url)) {
return $parser;
}
}
throw new InvalidNewsDataException("No parser found for URL: {$url}");
}
public function createCompositeParser(): CompositeRssParser
{
return new CompositeRssParser($this);
}
}
Step 5: Integration with CLI and Web
// Infrastructure/Cli/Commands/ExtractNewsCommand.php
class ExtractNewsCommand implements CliCommandInterface
{
private ExtractNewsUseCase $extractNewsUseCase;
private array $rssSources;
public function __construct(
ExtractNewsUseCase $extractNewsUseCase,
array $rssSources = []
) {
$this->extractNewsUseCase = $extractNewsUseCase;
$this->rssSources = $rssSources;
}
public function execute(array $args = []): void
{
echo "Starting RSS news extraction...\n";
$totalExtracted = 0;
foreach ($this->rssSources as $rssUrl) {
$extractedCount = $this->extractNewsUseCase->execute($rssUrl);
$totalExtracted += $extractedCount;
}
echo "Successfully extracted {$totalExtracted} news items total.\n";
}
}
In this example, the CLI command interacts with the Use Case from the Domain layer, which in turn uses the Repository interface that's implemented in the Infrastructure layer.
Real-World Use Cases
Hexagonal Architecture is highly versatile and can be applied to various kinds of projects:
- E-commerce platforms: Product catalog, order management, payment processing
- Content Management Systems: Article publishing, user management, media handling
- API backends: Data aggregation, third-party integrations, RESTful services
- News aggregators: Like our example - RSS parsing, content management, search functionality
Migration Strategies from Traditional Architectures
If you're working with a legacy PHP system, migrating to Hexagonal Architecture can seem daunting. Here's a practical strategy:
- Identify Core Logic: Start by identifying the core business logic of your application
- Extract Domain Entities: Move business objects to a Domain layer with proper validation
- Define Repository Interfaces: Create interfaces for data access patterns
- Implement Adapters Gradually: Replace direct database calls with repository implementations
- Create Use Cases: Extract complex business workflows into dedicated use case classes
- Refactor Controllers: Make controllers thin by delegating to use cases
This incremental approach helps you adopt Hexagonal Architecture without major disruptions.
Testing: Where Hexagonal Architecture Truly Shines
Here's where you'll fall in love with Hexagonal Architecture. Testing becomes not just easier, but actually enjoyable. No more wrestling with database connections in your unit tests or mocking half the internet just to test a simple business rule.
Unit Testing: Pure Logic, Pure Joy
Unit tests in Hexagonal Architecture are like examining individual gears in a well-oiled machine. Each component can be tested in complete isolation, with crystal-clear boundaries and predictable behavior. Here's how we test our ExtractNewsUseCase
:
// tests/Unit/Domain/News/UseCases/ExtractNewsUseCaseTest.php
class ExtractNewsUseCaseTest extends TestCase
{
private ExtractNewsUseCase $useCase;
private MockObject $newsRepository;
private MockObject $rssParser;
protected function setUp(): void
{
$this->newsRepository = $this->createMock(NewsRepository::class);
$this->rssParser = $this->createMock(RssParserInterface::class);
$this->useCase = new ExtractNewsUseCase(
$this->newsRepository,
$this->rssParser
);
}
public function testExecuteShouldSaveNewNewsItems(): void
{
// Arrange
$rssUrl = 'https://example.com/rss';
$newsEntity = new NewsEntity('Test Title', 'https://example.com/article');
$this->rssParser->expects($this->once())
->method('parseFromUrl')
->with($rssUrl)
->willReturn([$newsEntity]);
$this->newsRepository->expects($this->once())
->method('existsByUrl')
->with('https://example.com/article')
->willReturn(false);
$this->newsRepository->expects($this->once())
->method('save')
->with($newsEntity);
// Act
$result = $this->useCase->execute($rssUrl);
// Assert
$this->assertEquals(1, $result);
}
}
Integration Testing: When Components Dance Together
Integration tests are where we verify that our adapters actually fulfill their contracts. These tests ensure that our real implementations work correctly with actual databases, file systems, or external APIs:
// tests/Integration/Infrastructure/Database/MysqlNewsRepositoryTest.php
class MysqlNewsRepositoryTest extends TestCase
{
private MysqlNewsRepository $repository;
private PDO $connection;
protected function setUp(): void
{
// Setup test database connection
$this->connection = new PDO('sqlite::memory:');
$this->createNewsTable();
$this->repository = new MysqlNewsRepository();
}
public function testSaveShouldPersistNewsEntity(): void
{
// Arrange
$newsEntity = new NewsEntity(
'Test News',
'https://example.com/news'
);
// Act
$savedEntity = $this->repository->save($newsEntity);
// Assert
$this->assertNotEquals(0, $savedEntity->getId());
$this->assertTrue($this->repository->existsByUrl('https://example.com/news'));
}
private function createNewsTable(): void
{
$sql = "CREATE TABLE news (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
author TEXT,
published_date DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)";
$this->connection->exec($sql);
}
}
End-to-End Testing: The Full Symphony
End-to-end tests are like conducting a full orchestra - every component plays its part to create the complete user experience. These tests validate that your entire application works as intended from the user's perspective:
// tests/EndToEnd/NewsAggregatorE2ETest.php
class NewsAggregatorE2ETest extends TestCase
{
public function testCompleteNewsExtractionWorkflow(): void
{
// Arrange: Setup test RSS feed
$testRssUrl = 'https://httpbin.org/xml'; // Mock RSS endpoint
// Act: Run the CLI command
$output = shell_exec("cd src && php app/Infrastructure/Cli/index.php extract-news");
// Assert: Verify news was extracted and saved
$this->assertStringContains('Successfully extracted', $output);
// Verify via web interface
$webOutput = file_get_contents('http://localhost:8080');
$this->assertStringContains('PHP News', $webOutput);
}
}
The Do's and Don'ts: Hard-Earned Wisdom
After building countless Hexagonal Architecture applications, here's the battle-tested wisdom that will save you from common mistakes and guide you toward success.
Golden Rules for Success
π― Start Small, Think Big: Begin with simple interfaces and grow complexity only when needed
π― Type Everything: PHP's type system is your friend - use it to make your contracts bulletproof
π― Test-Drive Your Design: Write tests first to ensure your architecture actually works
π― Embrace Standards: PSR-4 autoloading and PSR-12 coding standards are your foundation
π― Composition Over Inheritance: Build flexible systems by combining simple parts
Landmines to Avoid
π₯ The Over-Engineering Trap: Don't create interfaces for every single class - keep it practical
π₯ The Anemic Domain: Your entities should have behavior, not just getters and setters
π₯ The Performance Blind Spot: Architecture is meaningless if your app crawls
π₯ The Silent Failure: Implement proper error handling or debugging becomes a nightmare
π₯ The Dependency Leak: Keep your arrows pointing inward - domain should never depend on infrastructure
Performance: Speed Without Compromising Clean Architecture
Clean architecture doesn't mean slow architecture! Here's how to keep your Hexagonal fortress running at lightning speed:
β‘ Database Optimization: Prepared statements and smart indexing are your best friends
β‘ Strategic Caching: Redis or Memcached can dramatically reduce database load
β‘ Connection Management: Pool your database connections for high-traffic scenarios
β‘ Profile Everything: Xdebug and profiling tools reveal hidden performance bottlenecks
β‘ Queue Heavy Work: Message queues handle long-running tasks without blocking users
From Chaos to Clean: Your Journey Complete
Congratulations! You've just witnessed the transformation from architectural chaos to clean, maintainable code. Our news aggregator isn't just a project - it's proof that you can build enterprise-grade PHP applications without drowning in framework complexity.
You now possess the knowledge to:
- Build applications that laugh in the face of changing requirements
- Write tests that actually make sense and run lightning-fast
- Swap out entire system components without breaking a sweat
- Sleep peacefully knowing your business logic is protected and pure
The Path Forward
Remember, Hexagonal Architecture isn't about building perfect code from day one. It's about building code that can evolve, adapt, and thrive in the face of inevitable change. Start small, apply these principles gradually, and watch your codebase transform from a maintenance nightmare into a joy to work with.
Your fellow developers will thank you. Your future self will thank you. And most importantly, you'll never have to fear that "simple" feature request again.
π Explore the Complete Implementation
Want to dive deeper into the code? Check out the complete news aggregator implementation on GitHub:
π° PHP News Aggregator - Complete Source Code
Clone it, explore it, and make it your own. Happy coding! π
Top comments (3)
You don't need hexagonal architecture for testing, just dependency injection and composition.
I prefer a modular approach. The main reason is that I want to keep related files together and I want a flatter file structure.
The domain-infrastructure file separation it more a burden than a blessing for me.
A bit off topic
One of the first rules I learned is never run database queries in a loop.
Nice one! Hexagonal Architecture is just one tool in the box (in this case, an architecture), and itβs not the only way to achieve clean, testable code. Also, HA is not only about the file structure, but I got your point.
I liked your modular approach, how do you handle cross-module dependencies at scale? Great catch on the query loop, that's a nasty N+1 waiting to happen, btw.
Yes I know, but the file structure is the one point that bothers me the most about it.
I understand why the file structure is that way, it provides separation. But I prefer more cohesion.
I assume you are thinking about application dependencies and not third party dependencies?
It depends on how much of the functionality is shared. If it is only a few methods or variables I tend to duplicate them in the modules. Otherwise it is a parent class or classes in the shared directory and child classes in the modules.
I noticed that the more functionality classes have in common, the more likely it is that a change in the parent class is for all child classes. But it is only a rule of thumb, there will be cases where it is needed to separate the classes.