When Your PHP Monolith Starts Cracking at the Seams
\nHave you ever found yourself in a situation where switching databases from MySQL to PostgreSQL turned into a week-long quest? Or when you needed to add a new API client, but the code was so intertwined with the framework that it was easier to rewrite everything from scratch? Sound familiar?
\nMost PHP developers sooner or later encounter "spaghetti code" in controllers and models. Frameworks like Laravel or Symfony provide convenient tools, but they also impose a tight coupling with themselves. There is a way out — Hexagonal Architecture (also known as "ports and adapters" or hexagonal architecture).
\nIn this article, we will break down how to build a PHP application that is independent of the framework, database, or external services. You will see code examples that you can immediately apply to your own project. Let's go!
\n\nWhat is Hexagonal Architecture and Why Do You Need It?
\nImagine a hexagon. In the center is your business logic (the domain). Around it are "ports" (interfaces) through which the outside world communicates with the core. On the outside are "adapters" that implement these ports: controllers, repositories, API clients.
\nThe main idea: the application core knows nothing about the outside world. It does not import Laravel, does not know about PDO, and does not call curl. All dependencies point inward — adapters depend on ports, not the other way around.
\nThe advantages of this approach are obvious:
\n- \n
- Testability: business logic can be tested without a database or HTTP requests. \n
- Flexibility: replacing a database, queue, or framework is simply a matter of writing a new adapter. \n
- Clean Code: you don't mix "what to do" with "how to do it". \n
Sounds like magic? Let's look at the code.
\n\nProject Structure: Basic Skeleton
\nFor an example, let's take a simple scenario: user registration. We need to save the user to the database and send a welcome email.
\nHere is what a typical directory structure would look like:
\nsrc/\n├── Domain/\n│ ├── Entity/\n│ │ └── User.php\n│ ├── Repository/\n│ │ └── UserRepositoryInterface.php\n│ ├── Service/\n│ │ └── UserRegistrationService.php\n│ └── Event/\n│ └── UserRegisteredEvent.php\n├── Infrastructure/\n│ ├── Persistence/\n│ │ └── DoctrineUserRepository.php\n│ ├── Mail/\n│ │ └── SymfonyMailerAdapter.php\n│ └── Controller/\n│ └── RegisterUserController.php\n└── Application/\n └── UseCase/\n └── RegisterUserUseCase.php\nNote: Domain is pure business logic. Infrastructure are adapters. Application are use cases.
Step 1: The Core — Domain and Ports
\nEntity
\nLet's start with the simplest thing — the user. This is just a class with data, no dependencies.
\n<?php\nnamespace App\\Domain\\Entity;\n\nclass User\n{\n public function __construct(\n private readonly string $id,\n private string $name,\n private string $email\n ) {}\n\n public function getId(): string\n {\n return $this->id;\n }\n\n public function getName(): string\n {\n return $this->name;\n }\n\n public function getEmail(): string\n {\n return $this->email;\n }\n}\n\nPort — Repository Interface
\nNow let's define how we will save the user. This is a port — an interface that will be implemented by an adapter.
\n<?php\nnamespace App\\Domain\\Repository;\n\nuse App\\Domain\\Entity\\User;\n\ninterface UserRepositoryInterface\n{\n public function save(User $user): void;\n public function findByEmail(string $email): ?User;\n}\n\nPort — Mail Service Interface
\nSimilarly for sending emails:
\n<?php\nnamespace App\\Domain\\Service;\n\nuse App\\