Domain-Driven Design (DDD) in PHP projects
In recent years, I have worked on projects with high domain complexity: e-commerce platforms and ERP systems. In such systems, the traditional service-layer approach quickly reaches its limits: business logic grows uncontrollably, dependencies become tangled, and the codebase turns into a chaotic, hard-to-maintain structure.
To address this, we applied Domain-Driven Design combined with layered architecture. This approach made it possible to isolate business rules from technical concerns, maintain clear boundaries between components, and keep the project manageable over the long term.
This architecture is framework-agnostic. It fits both Symfony and Laravel, because the business logic (Domain + Application) does not depend on framework features.
Folder structure
src
├── Catalogue/ # Catalogue Bounded Context
│ ├── Application/ # Use cases (commands, queries, handlers, ports)
│ ├── Contracts/ # Published Language (DTOs, ports for other BCs)
│ ├── Domain/ # Entities, value objects, repos, exceptions
│ ├── Infrastructure/ # Adapters: OHS, Doctrine, Validation
│ └── Presentation/ # API controllers
│
├── Order/ # Order Bounded Context
│ ├── Application/ # Commands, queries, ports
│ ├── Domain/ # Pure Order model
│ ├── Infrastructure/ # Doctrine, Validation
│ ├── Integration/ # ACL to Catalogue
│ └── Presentation/ # HTTP/CLI controllers
│
└── SharedKernel/ # Cross-cutting primitives
├── Domain/ # Value objects, interfaces
├── Http/ # Transport-agnostic HTTP helpers
└── Infrastructure/ # Error responders, exception mapping
Explore full demo project: on GitHub
Architecture Layers
Each bounded context is split into four core layers.
Domain
- Pure business logic: entities, aggregates, value objects, invariants.
- Independent of Symfony, Doctrine, or any technical framework.
class Order {
public static function create(string $id, Money $amountToPay, OrderLine ...$lines): self
{
$amountToPayValue = $amountToPay->toMinor();
if ($amountToPayValue <= 0) {
throw new NonPositiveOrderAmountException("Order amount must be greater than zero.");
}
$order = new self();
$order->id = $id;
$order->amountToPay = $amountToPayValue;
$order->status = OrderStatus::PENDING->value;
$order->createdAt = new \DateTime();
foreach ($lines as $l) {
$order->items[] = OrderItem::create($order, $l->productId(), $l->getName(), $l->quantity(), $l->price()->toMinor());
}
return $order;
}
}
Application
- Defines the system’s use cases through CommandHandler and QueryHandler.
- A Handler is the single entry point for executing a use case:
- CommandHandler — for state changes (create, update, delete).
- QueryHandler — for retrieving data within the business context.
- All commands and queries go through a Handler, which prevents bypassing business rules and ensures controlled execution paths.
- A Handler coordinates domain objects and repositories, performs validation, and runs everything inside a transaction.
- Because validation happens inside the Handler, data is always verified regardless of the entry point (HTTP, CLI, queue), ensuring consistent behavior across the system.
class CreateProductCommandHandler
{
public function __construct(
private ProductRepositoryInterface $productRepository,
private CommandValidatorInterface $commandValidator,
private TransactionRunnerInterface $transactionRunner
) {
}
public function __invoke(CreateProductCommand $command): Product
{
$this->commandValidator->assert($command);
return $this->transactionRunner->run(function () use ($command) {
$product = Product::create($command->id, $command->name, Money::fromMinor($command->price), $command->onHand);
$this->productRepository->add($product);
return $product;
});
}
}
Infrastructure
- Technical details: Doctrine mappings, repository implementations, framework glue.
- Implements ports defined by Application.
class ProductRepository implements ProductRepositoryInterface
{
public function __construct(private readonly EntityManagerInterface $entityManager)
{
}
public function get(string $productId): ?Product
{
return $this->entityManager->getRepository(Product::class)->findOneBy(['id' => $productId]);
}
Presentation
- Entry points: HTTP controllers, CLI commands.
- Receives requests, validates input, builds a Command/Query, and calls a Handler.
Bounded Contexts Architecture
Order BC
Owns orders, statuses, and reservation workflow. Source of truth for Order and OrderItem. Integrates with Catalogue only via Contracts (OHS/ACL).
Catalogue BC
Owns products and stock. Source of truth for Product and Stock. Exposes APIs for stock reservation and fulfillment.
⸻
Data & Persistence Rules
- Foreign keys only inside one BC. Cross-BC references are stored as IDs/values, never relational FKs.
- Clear ownership. Only the owning BC mutates its data. Others consume via Contracts or projections.
- No cross-BC transactions. An Order transaction commits first, then calls Catalogue OHS (HTTP/queue).
This ensures strict boundaries, stable contracts, and scalable microservice extraction.
Bounded Contexts Communication
Open Host Service (OHS)
The Catalogue BC exposes its functionality via an Open Host Service (OHS) - a public API for other contexts.
- Defined in Contracts as interfaces and DTOs (Published Language).
- Ensures stability of external integration even if Catalogue internals change.
- Implemented in Infrastructure/Ohs.
Contract example:
namespace App\Catalogue\Contracts\Reservation;
interface CatalogueStockReservationPort
{
public function reserve(CatalogueReserveStockRequest $request): CatalogueReservationResult;
}
Implementation (OHS Adapter):
namespace App\Catalogue\Infrastructure\Ohs;
use App\Catalogue\Application\Command\Handler\ReserveStockCommandHandler;
use App\Catalogue\Application\Command\ReserveStockCommand;
use App\Catalogue\Contracts\Reservation\CatalogueReservationResult;
use App\Catalogue\Contracts\Reservation\CatalogueStockReservationPort;
use App\Catalogue\Contracts\Reservation\CatalogueReserveStockRequest;
class CatalogueStockReservationService implements CatalogueStockReservationPort
{
public function __construct(private ReserveStockCommandHandler $handler)
{
}
public function reserve(CatalogueReserveStockRequest $request): CatalogueReservationResult
{
try {
$command = new ReserveStockCommand($request->items);
($this->handler)($command);
return CatalogueReservationResult::ok();
} catch (\Throwable $e) {
return CatalogueReservationResult::fail($e->getMessage());
}
}
}
Anti-Corruption Layer (ACL)
On the consumer side, Order BC integrates with Catalogue via an ACL:
- Implements its own ports (StockReservationPort, etc.).
- Delegates to Catalogue’s OHS Contracts.
- Adapts responses into its own model without leaking foreign domain details.
namespace App\Order\Integration\Catalogue;
use App\Catalogue\Contracts\Reservation\CatalogueReserveStockRequest;
use App\Catalogue\Contracts\Reservation\CatalogueStockReservationPort;
use App\Order\Application\Port\Dto\ReservationRequest;
use App\Order\Application\Port\Dto\ReservationResult;
use App\Order\Application\Port\StockReservationPort;
readonly class StockReservationAdapter implements StockReservationPort
{
public function __construct(private CatalogueStockReservationPort $reservation)
{
}
public function reserve(ReservationRequest $request): ReservationResult
{
$catalogueRequest = new CatalogueReserveStockRequest(
array_map(fn($i) => ['product_id' => $i['product_id'], 'quantity' => $i['quantity']], $request->items),
['order_id' => $request->orderId]
);
$reservationResult = $this->reservation->reserve($catalogueRequest);
return $reservationResult->success ? ReservationResult::ok() : ReservationResult::fail($reservationResult->reason);
}
}
Microservice-ready design
The Catalogue BC can be extracted into a standalone microservice with minimal changes:
- Contracts stay stable — DTOs and ports define the published language, reused by both sides.
- Domain & Application unchanged — business logic and use-cases move as-is.
- Infrastructure adapters switch — OHS becomes an HTTP/queue endpoint in Catalogue; ACL in Order becomes an HTTP/queue client.
This design ensures you can scale from monolith to microservices by replacing only the transport layer, not rewriting core logic.
Deptrac: Enforcing architectural boundaries
In complex systems it’s easy to break DDD principles - for example, accidentally pulling Symfony or Doctrine into the Domain, or letting Presentation call repositories directly.
To prevent this, I use Deptrac as an automatic architectural linter.
Purpose of Deptrac
- Protect the Domain and Application: no dependencies on Symfony, Doctrine, or infrastructure code.
- Explicit layer boundaries: Presentation can only see Application, Application can only see Domain and Shared, and Domain depends exclusively on SharedDomain.
- Control between bounded contexts: interaction between Order and Catalogue goes strictly through Contracts and ACL, never directly.
- Documented rules: the deptrac.yml file serves as living documentation of the architecture.
Usage
- Locally: vendor/bin/deptrac analyze deptrac.yml
- In CI: every pull request is checked for boundary violations. If Presentation tries to access Domain directly, the build fails.
- Result: the team always gets immediate feedback when someone attempts to bypass the architecture.
Full deptrac.yml: on GitHub
DDD Testing
In these projects I deliberately structure the test pyramid around business logic, keeping it isolated from frameworks and infrastructure. The main layers are:
-
Domain tests
- Focus on invariants and business rules.
- Executed completely isolated from the database, Symfony, or any technical dependencies.
- Examples: an Order cannot be created with a zero amount; a Product cannot have negative stock.
public function test_create_order_ok(): void
{
$lines = [
['p1', 'Product 1', 1, 500],
['p2', 'Product 2', 2, 500],
];
$order = Order::create(
'ord-1',
Money::fromMinor(1500),
...array_map(fn($l) => $this->line(...$l), $lines),
);
self::assertSame('ord-1', $order->getId());
self::assertSame(1500, $order->getAmountToPay());
$items = [...$order->getItems()];
self::assertCount(count($lines), $items);
foreach ($lines as $i => [$pid, , $qty, $price]) {
$item = $items[$i];
self::assertSame($pid, $item->getProductId());
self::assertSame($qty, $item->getQuantity());
self::assertTrue(Money::fromMinor($price)->equals($item->getPrice()));
}
}
-
Application tests
- Verify Handlers as use cases - the single entry points into business logic.
- Cover: validation through the CommandValidatorInterface contract, execution within a transaction, repository interactions, and reaction to domain events or exceptions.
- Use in-memory adapters for repositories and transactions to stay independent of infrastructure.
- Ensure business scenarios remain consistent regardless of the runtime environment.
Framework and technical concerns (Symfony, Doctrine, transport adapters) are covered only by a thin layer of integration tests - limited to critical HTTP scenarios that verify correct request → command → response mapping.
Full tests: on GitHub
Summary
- Pure Domain — business logic lives in clean entities and value objects, completely free from Symfony or Doctrine.
- Application as a gateway — every use case runs through a Handler, ensuring validation, transactions, and orchestration are always consistent.
- Clear boundaries — Bounded Contexts talk only via Contracts (OHS/ACL). No shortcuts, no leaking of internals.
- Enforced architecture — Deptrac acts as an architectural linter, keeping the codebase aligned with DDD principles over time.
- Layered testing — Domain and Application are tested in isolation, without frameworks, while only critical scenarios are verified end-to-end.
By combining these practices, the project stays maintainable under high domain complexity, and the architecture itself becomes part of the product’s quality and business value.
Explore full demo project: on GitHub
📬 Let’s Connect
If you’re looking for a senior developer to solve complex architecture challenges or lead critical parts of your product — let’s talk.
Top comments (6)
While I get it is a post about DDD, there is hardly a mention of Symfony.
When I see ACL i think of access control list, so that could be confusing. As I understand it from your post is seems to be more of a transport layer than having something to do with security.
There were a few things I discovered in the repository:
JsonResponse
in your controllers. I'm curious who you are handling html responses.Maybe points for your next post?
Thanks a lot for the review and remarks. My answers:
1.The project is fully built on Symfony (see GitHub). In the article I focused on DDD concepts since Domain/Application are framework-agnostic. If I had also shown more Symfony examples, the post would have become too large and harder to follow.
2.ACL – here ACL means Anti-Corruption Layer (DDD pattern), not Access Control List. I’ll update the naming to avoid confusion.
3.CommandValidator – you are right, the contract should be placed in SharedKernel\Application, with concrete implementations in each BC’s Infrastructure.
4.XML mapping – I use XML simply because it’s more convenient for me than YAML, and it keeps Domain free from Doctrine attributes.
5.Validation – Symfony Validator is only used to check input data in commands/DTOs, so it belongs to Infrastructure. Business invariants remain inside entities and value objects.
6.JsonResponse – the demo only shows API endpoints. For HTML responses I would add controllers in Presentation/Web
1 Yes that was my guess. But because the title is DDD in Symfony I was hoping for a little more Symfony related content.
2 I just think it is a badly named pattern, and the abbreviation doesn't help.
4 It has been so long I used separate ORM configuration I forgot it existed.
5 You are right it is infrastructure validation.
5 and 6 The reason i asked the question was also about your stance on changing the framework to have a more discoverable DDD structure. For example a config directory or the views in Infrastructure/views in each bounded context.
1.Yes, makes sense. To clarify — the whole Infrastructure in the Github demo is built with Symfony (controllers, DI config, validation, etc.). I kept the post framework-agnostic to highlight the DDD boundaries, but the implementation is fully Symfony.
4.On ORM config — true, it’s not used often these days. Out of curiosity, how do you usually separate ORM mapping from the domain layer in your projects?
I moved to native query languages in the infrastructure layer. I don't want to think about two different types of models in one project.
Letting the ORM loose I also feel more free to use multiple database types.
That’s a very interesting approach — thanks for sharing. Can we connect on Linkedin or somewhere else?