Here’s a sarcastic summary of the article in a format with hashtags:
π Sarcastic Summary of the Domain-Driven Design (DDD) in PHP projects
- Domain-Driven Design (DDD) in PHP projects is a framework-agnostic approach that separates domain logic from technical concerns, promoting separation of concerns and a clear separation of concerns between business logic and the application layer.
- By isolating business logic from the application layer, DDD allows for the separation of concerns and the ability to maintain clear boundaries between components. This approach is not limited to Symfony or Doctrine, as it can work with both frameworks.
- DDD is a framework-agnostic framework that can be applied to both Symfony and Laravel, making it suitable for both frameworks. The business logic (Domain) is not tied to any specific framework, ensuring flexibility and scalability.
- DDD’s architecture layers, such as Domain, Application, Infrastructure, and Presentation, provide a layer of abstraction that separates the application’s high-level domain logic from the infrastructure and presentation layers. This separation helps to maintain a clear and consistent architecture, making it easier to manage and maintain over time
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
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 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.