PHP DDD & Hexagonal Architecture
A structured approach to building domain-driven software with clear separation of concerns and hexagonal architecture.
Layered architecture — from outside to inside
Domain-Driven Design Principles
DDD proposes that software design should be guided by the business domain, creating close collaboration between technical and domain experts.
The three core principles
- Ubiquitous Language — Software and domain experts work together to establish a common language. This vocabulary is reflected directly in the code: class names, methods, and variables.
- Strategic Design — Covers not only technical aspects but also the strategy behind business logic. Defines Bounded Contexts, context maps, and team relationships.
- Tactical Design — Provides a structure that enables iterative software deliverables. Aims to make software testable and less error-prone. Includes the building blocks: Value Objects, Entities, Aggregates, Repositories, and Services.
Core goal: The domain model and the code must speak the same language. If the business says "Order", the code has an Order class — not PurchaseRecord or TxnData.
The 5 tactical building blocks
Value Objects
Immutable, defined by their attributes, with no individual identity.
Entities
Have a unique identity that persists over time.
Aggregates
Consistency boundaries grouping Entities and Value Objects.
Repositories
Persistence contracts — the domain layer defines the interface.
Services
Logic that does not fit naturally into entities or value objects.
Value Objects
A fundamental DDD building block. Used to represent business logic concepts: measurements or descriptions of something. Examples: money, dates, coordinates, emails.
Identity based on state, not reference
Every $20 bill is unique by its serial number, but economically all $20 bills are worth the same. Two Value Objects are equal if all their attributes are equal. This distinguishes them from Entities, whose equality depends on their identifier.
When to model as a Value Object?
- Measures, quantifies, or describes something in the domain
- Can be kept immutable — if it needs to change, a new one is created
- Must not hold references to Entities (those are mutable)
- Can be compared to another by structural or value equality
- Can be fully replaced when the measurement or description changes
- Models a concept by composing related attributes as a single integral unit
- Collaborators benefit from side-effect-free behaviour
PHP implementation
final class Money
{
public function __construct(
private readonly int $amount,
private readonly string $currency
) {}
public static function of(int $amount, string $currency): self
{
return new self($amount, $currency);
}
public function equals(self $other): bool
{
return $this->amount === $other->amount
&& $this->currency === $other->currency;
}
public function add(self $other): self
{
// Returns a NEW Money object — never mutates
return new self($this->amount + $other->amount, $this->currency);
}
}
Golden rule: to create a Value Object in PHP, prefer static methods calling self as semantic constructors. Never mutate the object; always return a new one.
Entities
While everything should be modelled with Value Objects where possible, there are situations requiring a thread of identity. An entity can change its attributes but always retains the same identity.
Key characteristics
- Its equality is determined solely by its unique identifier, not its attributes
- No matter how many times its values change — the identity remains constant
- The identifier is usually a primitive type (integer, UUID string), but can be represented as a Value Object
Example: A Person can change their name or address, but they are still the same person identified by their PersonId.
Why use a Value Object as an ID?
- Immutability — the ID never changes and cannot be accidentally mutated
- Complex types — unlike primitives, they allow encapsulating methods like format validation or equality comparison
- Type safety — prevents confusing a
UserIdwith anOrderIdeven though both are UUIDs
final class UserId
{
public function __construct(
private readonly string $value
) {}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}
class User
{
public function __construct(
private UserId $id,
private string $email
) {}
public function id(): UserId
{
return $this->id;
}
}
Aggregates (Aggregate Roots)
The most complex DDD building blocks to model. They are carefully crafted boundaries that group Entities and Value Objects into a consistent transactional unit.
Definition and purpose
- Are collections of related objects treated as a unit in terms of consistency and data management
- Distinguished from generic collections by being concepts mapped directly from business logic
- Internal objects are only accessed through the Aggregate Root — this enforces the business invariant
- Persisted and retrieved as a complete unit through a Repository
Classic example: An invoice (Invoice) contains many line items (InvoiceItem). An InvoiceItem is never accessed directly — always through the Invoice. This ensures the calculated total is always consistent with the items.
Design rules
- Keep aggregates small — large aggregates hinder concurrency
- Reference other aggregates by ID only, never by direct object reference
- Enforce business rules inside the aggregate, not in external services
Key differences
| Concept | Equality by | Mutability |
|---|---|---|
| Value Object | Structure/value | Immutable |
| Entity | Identifier | Mutable |
| Aggregate | Root (ID) | Mutable (as a unit) |
Services in DDD
When an entity or value object is not naturally responsible for a transformation or process, an interface is added to map the operation. This is called a service.
The three types of services
Application Services
Operate on scalar types — primitives that carry no domain meaning. They are the entry point from the outside world: they transform DTOs into domain concepts and coordinate use cases.
Domain Services
Operate only with domain types directly. They contain operations that do not fit naturally into entities and value objects, such as domain logic involving multiple aggregates.
Infrastructure Services
Contain infrastructure operations: sending emails, logging data, sending notifications. In hexagonal architecture, they live outside the domain layer.
How many methods per service?
One method per service
Recommended approach. Maintains high cohesion and robustness. Each service does exactly one thing — the method is called execute() or __invoke(). Easy to test and reason about.
Multiple methods
Possible but less recommended. Can create implicit coupling between operations within the same service and complicate isolated testing.
Key principle: Application Services are the bridge between APIs or forms and the application domain. They receive commands (DTOs) from the outside and orchestrate entities, value objects, and domain repositories.
Application Services and Handlers
Application Services are also called Handlers. They are responsible for handling requests that arrive as Commands (DTOs).
Commands (DTOs)
- Primitive types only — contain no domain objects
- Serializable — can travel over the network or queue
- No business logic — they are data containers
- Not tested directly — the logic lives in the Handler
final class SignupUserCommand
{
public function __construct(
public readonly string $email,
public readonly string $password,
public readonly string $name
) {}
}
Handler anatomy
Dependency Inversion Principle (DIP)
The Handler does not depend on a concrete implementation of the repository, but on a contract (interface). The concrete implementation is resolved at runtime by the dependency container.
Repositories
Are in-memory locations that return an object exactly as it was persisted. They are the mechanism that connects the domain with the data mapping layer, acting as an in-memory collection of objects.
Collection API, not CRUD
- Instead of
save(),update(),delete()— useadd(),remove(),findWhere() - No implementation hints: domain code does not know whether it persists in a DB, in memory, or in files
- Data is persisted at the moment the action happens — no need for an explicit
save()method
interface UserRepositoryInterface
{
public function add(User $user): void;
public function remove(UserId $id): void;
public function findById(UserId $id): ?User;
public function findByEmail(Email $email): ?User;
}
Repositories in each layer
| Layer | Repository role |
|---|---|
| Domain | Defines the interface. Declares the functions required by the application layer. |
| Application | Only knows which functions to use (read, write, update). Works with the interface, never with the implementation. |
| Infrastructure | Where data is actually persisted. Implements the domain interface with Doctrine, Eloquent, etc. |
PicoMapper: Tithely's open source library that provides a Data Mapper, allowing entities to be transformed for persistence. Used in the Infrastructure/Domain layer.
Hexagonal Architecture (Ports & Adapters)
Hexagonal architecture organises software into concentric layers where the domain is the core and dependencies always point inward. Each outer layer communicates with the inner one through ports and interfaces.
Layers from outside to inside
Request flow
Fundamental rule: dependencies always point inward. The Domain layer knows nothing about Application or Infrastructure. Application knows Domain. Infrastructure knows both.
Tithely Project Layout
The project structure directly mirrors the architecture layers. Each directory has a single, well-defined responsibility.
Directory tree
app/
Application/
<Feature>/
<DoSomething>Command.php
<DoSomething>Handler.php
Domain/
<Feature>/
<Entity>.php
<EntityId>.php
<VO>.php
<Entity>RepositoryInterface.php
Infrastructure/
Api/
Resource/
<Feature>/
<Entity>Resource.php
Application/
HandlerBus.php
Domain/
<Entity>Repository.php
HTTP/
Controller/
<Feature>/
<Entity>Controller.php
Middleware/
Query/
<Feature>Finder.php
ServiceProvider/
Configuration files
config/
api/
db/
migrations/
events.php
handlers.php
routes.php
tests/
Key files
handlers.php— maps Commands to their concrete Handlersroutes.php— defines the API's HTTP routesevents.php— registers domain events
Convention: the directory name determines the layer. Application/ holds only handlers and commands. Domain/ holds only entities, VOs, and interfaces. Infrastructure/ holds everything that depends on frameworks or external implementations.
Handlers Configuration
The handlers.php file is the configuration file that maps Commands to Handlers. It defines the concrete implementation for each exposed contract, connecting services with their DTOs.
Mapping Commands to Handlers
// Command::class => 'container.service.key'
Application\Gym\AddGymCommand::class => 'application.gym.add',
Application\Gym\UpdateGymCommand::class => 'application.gym.update',
Application\User\SignupUserCommand::class => 'application.user.signup',
Container definition (Providers)
Handlers are connected directly with providers. The $c function acts as a dependency container — it builds objects from a key.
// 1. Concrete repository — injects the domain interface implementation
$c['repository.gym'] = function ($c) {
return new DbGymRepository($c['persistence.mapper']);
};
// 2. Handler — receives the concrete repository (resolved above)
$c['application.gym.add'] = function ($c) {
return new AddGym(
$c['repository.gym']
);
};
Dependency inversion in action: the Handler AddGym receives a GymRepositoryInterface. The provider decides which concrete implementation (DbGymRepository) is injected at runtime.
Recommended approach: one service (Handler) per Command, with a single execution method. This maximises cohesion and simplifies testing — each test instantiates only the Handler being tested.
Application Layer & Domain Layer
The two inner layers of hexagonal architecture. The domain is the pure business core; the application is the orchestrator that connects the outside with the domain.
Application Layer
Contains
All Services (Handlers) and behaviour-less classes used as DTOs (Commands).
- Separates the domain layer from external clients
- Makes queries and modifies values using domain contracts
- Is the bridge between APIs/forms and domain logic
- Contains no business logic — only orchestration
// To sign up:
// 1. Get email and password from the client
// 2. Check if the email already exists
// 3. Create the user (domain Entity)
// 4. Add it to the repository
// 5. Return the created user
Domain Layer
Contains
The Entities, relationship mappings, and Value Objects, as well as the contracts for repository usage (interfaces).
- Zero external dependencies — no frameworks or infrastructure libraries imported
- Defines the repository interfaces that the infrastructure layer will implement
- Contains all business logic in entities and value objects
- Is the most stable layer — changes only when the business changes
Rule: if you need to import a Doctrine, Eloquent, or Laravel class into the domain layer, something is wrong. The domain speaks only its own language.
Infrastructure Layer
Contains all code that depends on frameworks, external libraries, or implementation details: controllers, UI, persistence, gateways to external services, and more.
Infrastructure sub-layers
Infrastructure/Application
Contains the HandlerBus — responsible for correctly dispatching commands to their handlers. It is the bus that routes a Command to the corresponding Handler.
Infrastructure/Domain
The project's concrete repositories. They implement the interfaces defined in the domain layer. This is where database access code lives.
Infrastructure/Query
Finders — similar to repositories but separated from the domain. Their queries are processed to be sent directly to the API. They return primitives, not domain objects.
Infrastructure/Resources
Routes, controllers, and API resources. The controller is the application's entry point. Resources handle different API response standards.
HandlerBus — Infrastructure/Application
The HandlerBus is the mechanism that receives a Command and dispatches it to the correct Handler, as configured in handlers.php.
Concrete Repositories & PicoMapper
This sub-layer implements the repository interfaces defined in the domain. The PicoMapper library (Tithely open source) provides a Data Mapper to transform entities into a persistable format.
PicoMapper methods
| Method | Description |
|---|---|
| withColumns | Defines the columns to access. Creation/modification date columns are typically excluded. |
| withDeletionTimestamp | Determines the field used for soft delete. |
| withCreationData | Determines whether the entity creation timestamp will be saved. |
| withModificationData | Similar to creation, but for the last modification timestamp. |
| withMany | Defines a one-to-many relationship. |
| withOne | Defines a one-to-one relationship. |
Mapping example — Gym with monthly packages
private function mapping(): Mapping
{
$monthlyPackageDefinition = (new Definition('gym_monthly_package'))
->withColumns('name', 'price', 'free_months')
->withDeletionTimestamp('deleted');
$gymDefinition = (new Definition('gym'))
->withColumns('location')
->withMany($monthlyPackageDefinition, 'gym_monthly_package', 'gym_id')
->withDeletionTimestamp('deleted');
return $this->mapper->mapping($gymDefinition);
}
Separation of concerns: the concrete repository class implements the domain interface and delegates data access to PicoMapper. The domain entity never "knows" how it is persisted.
Finders — Infrastructure/Query
Finders are similar to repositories, but are separated from the domain layer. Their queries are optimised to be sent directly to the API as a response.
Differences: Finder vs Repository
| Aspect | Repository | Finder |
|---|---|---|
| Layer | Domain (interface) + Infra (impl.) | Infrastructure only |
| Returns | Entities / Value Objects | Primitives / read DTOs |
| Usage | Domain write + read | API read-only |
| Purpose | Domain invariants | Presentation queries |
Rule: Finders never return domain collections. The returned data is created within the layer itself — classes similar to domain ones but not extending EntityId, designed as read DTOs for the API.
class UserFinder
{
public function __construct(
private Mapper $mapper
) {}
public function findOne(Query $query): ?User
{
$mapping = $this->mapping();
if ($query->getParameter('id')) {
$mapping->eq(
'id',
$query->getParameter('id')
);
}
$data = $mapping->findOne();
if (!$data) {
return null;
}
// Returns a read-model, not a domain entity
return new User(
$data['id'],
$data['first_name'],
$data['last_name'],
$data['email']
);
}
private function mapping(): Mapping
{
$user = (new Definition('users'))
->withColumns(
'id', 'email',
'first_name', 'last_name'
)
->withDeletionTimestamp('deleted')
->readOnly();
return $this->mapper->mapping($user);
}
}
Controllers, Resources, and Routes
The Resources sub-layer contains routes, controllers, and API resources. The controller is the application's entry point — it receives the HTTP request and dispatches the Command to the Handler.
The controller as entry point
- Either a
Finder(for read queries) or theHandlerBus(for write operations) is injected - Each controller method maps to a route defined in
routes.php - Resources sit above controllers and handle different API response standards
// config/routes.php
$routes->get('/v1/users', [
UserController::class, 'list'
]);
$routes->get('/v1/users/{id}', [
UserController::class, 'show'
]);
$routes->post('/v1/users', [
UserController::class, 'create'
]);
class UserController extends BaseController
{
public function __construct(
private UserFinder $finder
) {}
/**
* Returns a collection of users.
*/
public function list(): Response
{
$users = array_map(
fn($user) => self::toArray($user),
$this->finder->findAll()
);
return $this->responseWithJson($users);
}
/**
* Returns a single user.
*/
public function show(string $id): Response
{
$query = new Query(['id' => $id]);
$user = $this->finder->findOne($query);
if (!$user) {
return $this->responseNotFound();
}
return $this->responseWithJson(
self::toArray($user)
);
}
}
Resources: can be used above controllers to handle different API response standards (data transformation, versioning, etc.).
API Versioning with OpenAPI
Versioning is done with OpenAPI 3.0. Separate files with dates determine the API versions. Each file defines servers, available routes, and general metadata.
OpenAPI file structure
openapi: 3.0.3
info:
title: Kata
description: Tithe.ly Kata training
version: 1.0.0
contact:
email: chris.marsh@tithe.ly
name: Chris Marsh
servers:
- url: 'http://localhost:8080'
description: 'Tithe.ly Kata [LOCAL]'
paths:
'/v1/gyms':
get:
summary: Get Gym list
security:
- BearerAuth: []
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
data:
$ref: '#/components/schemas/GymCollection'
'403':
$ref: '#/components/responses/Forbidden'
tags:
- Gym
Anatomy of each route
- summary — a brief description of the request
- security — middlewares responsible for authenticating and authorising the user
- responses — potential responses (200, 403, 404, 422, etc.)
- content — the response payload for a 200, defined with a schema
- tags — grouping of routes by domain (Gym, User, Payment, etc.)
Components section
- schemas — definitions of reusable data models
- responses — common error responses referenceable with
$ref - securitySchemes — definition of BearerAuth, ApiKey, etc.
File-based versioning: separate files are created per date or version (e.g., 2024-01-01.yaml). This allows the API to evolve without breaking existing clients.
Request Structure
The complete request flow in hexagonal architecture. Each stage has a single responsibility and communicates with the next through well-defined interfaces.
End-to-end flow
POST /v1/gymsCourse summary: Value Objects + Entities + Aggregates form the Domain. Handlers + Commands form the Application. Controllers + Concrete Repositories + Finders + HandlerBus form the Infrastructure. Dependencies always flow inward — the Domain knows no one else.