
Table of Contents
By Khimananda Oli | Last reviewed: August 2026
Domain-Driven Design for PHP applications solves the problem of tangled business logic buried inside controllers and database queries by organizing code around core business concepts rather than technical frameworks. When your Laravel or Symfony project grows beyond simple CRUD, applying DDD principles prevents the "big ball of mud" anti-pattern that stalls development velocity. This guide translates Eric Evans’ strategic patterns into concrete PHP 8.4 implementations suitable for modern production systems.
How do you define bounded contexts in Domain-Driven Design for PHP applications?
Bounded contexts are the primary strategic pattern in Domain-Driven Design for PHP applications. A bounded context defines a boundary within which a particular domain model applies and has a specific meaning. In practice, this means the term "Product" might mean something different in your Catalog context versus your Shipping context. Without explicit boundaries, teams merge these conflicting definitions into a single bloated Eloquent model, creating maintenance nightmares.
Start by identifying linguistic boundaries during event storming sessions with domain experts. If the same word triggers confusion or requires qualifiers like "ShippingProduct" vs "CatalogProduct," you likely have two distinct contexts. For PHP projects, enforce these boundaries physically through directory structure or separate packages before writing any code.
Enforcing boundaries in monolithic PHP
You do not need microservices to apply DDD. A modular monolith with strict namespace isolation works effectively for most Nepali SMEs and global startups. Structure your application so each context lives in its own top-level namespace, sharing only explicit contracts.
<?php
// src/OrderContext/Domain/Model/Order.php
namespace App\OrderContext\Domain\Model;
use App\SharedKernel\Domain\Event\OrderPlaced;
use Ramsey\Uuid\UuidInterface;
final class Order
{
private UuidInterface $id;
private array $lineItems;
private OrderStatus $status;
public function place(): void
{
if ($this->status !== OrderStatus::Draft) {
throw new \DomainException('Only draft orders can be placed');
}
$this->status = OrderStatus::Placed;
$this->recordThat(new OrderPlaced($this->id));
}
} Notice the absence of framework imports. The domain model depends only on shared kernel primitives. If you find yourself importing Illuminate\Support\Facades or Doctrine annotations inside a domain entity, you have violated the boundary. Refer to microservices vs monolith trade-offs to decide when physical separation becomes necessary over logical separation.
What is the difference between entities and value objects in PHP DDD?
Entities possess identity and lifecycle continuity, while value objects describe characteristics without conceptual identity. Confusing these two building blocks is the most common mistake when implementing Domain-Driven Design for PHP applications. Entities are mutable and tracked by ID; value objects are immutable and compared by structural equality.
- Entity: Has a unique identifier (UUID), changes state over time, represents a business concept like
CustomerorInvoice. - Value Object: No unique ID, immutable after creation, self-validating, represents descriptors like
Money,EmailAddress, orCoordinate. - Primitive Obsession: Using strings/integers where value objects belong. Replace
string $emailwithEmailAddress $emailto embed validation at the type level.
Implementing self-validating value objects
Value objects should validate themselves upon construction. Never allow an invalid instance to exist. This eliminates scattered validation logic throughout services and controllers.
<?php
declare(strict_types=1);
namespace App\BillingContext\Domain\ValueObject;
final readonly class Money
{
public function __construct(
private int $amountInCents,
private string $currencyCode
) {
if ($amountInCents < 0) {
throw new \InvalidArgumentException('Amount cannot be negative');
}
if (!preg_match('/^[A-Z]{3}$/', $currencyCode)) {
throw new \InvalidArgumentException('Invalid ISO currency code');
}
}
public function add(Money $other): self
{
if ($this->currencyCode !== $other->currencyCode) {
throw new \DomainException('Cannot add different currencies');
}
return new self(
$this->amountInCents + $other->amountInCents,
$this->currencyCode
);
}
public function equals(Money $other): bool
{
return $this->amountInCents === $other->amountInCents
&& $this->currencyCode === $other->currencyCode;
}
} This Money class uses PHP 8.4’s readonly modifier to guarantee immutability. Business rules about currency compatibility live inside the object itself, not in external validators. When modeling financial systems common in Nepal’s fintech sector, this pattern prevents catastrophic rounding errors and currency mismatches. See data protection basics for Nepal fintech for compliance considerations that influence domain modeling.
How do you implement repositories and domain services in PHP?
Repositories provide persistence ignorance, allowing the domain to operate without knowledge of the underlying storage mechanism. Domain services encapsulate business logic that doesn’t naturally belong to a single entity or value object. Together, they form the operational backbone of Domain-Driven Design for PHP applications.
A critical distinction: repositories persist and retrieve aggregates, never individual entities. An aggregate is a cluster of associated objects treated as a unit for data changes. Always reference other aggregates by ID, never by direct object reference, to maintain consistency boundaries.
Defining repository interfaces in the domain layer
The interface belongs in the domain layer; the implementation belongs in infrastructure. This inversion of dependency is non-negotiable for testability and framework independence.
<?php
// Domain Layer - Interface only
namespace App\OrderContext\Domain\Repository;
use App\OrderContext\Domain\Model\Order;
use Ramsey\Uuid\UuidInterface;
interface OrderRepositoryInterface
{
public function findById(UuidInterface $id): ?Order;
public function save(Order $order): void;
/** @return Order[] */
public function findPendingOrdersOlderThan(\DateTimeImmutable $date): array;
}
// Infrastructure Layer - Implementation
namespace App\OrderContext\Infrastructure\Persistence\Doctrine;
use App\OrderContext\Domain\Repository\OrderRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
final class DoctrineOrderRepository implements OrderRepositoryInterface
{
public function __construct(
private EntityManagerInterface $em
) {}
public function findById(\Ramsey\Uuid\UuidInterface $id): ?Order
{
return $this->em->find(Order::class, $id);
}
public function save(Order $order): void
{
$this->em->persist($order);
$this->em->flush();
}
} Domain services handle cross-entity operations. For example, transferring funds between accounts involves two Account aggregates and transactional integrity. Place this logic in a FundTransferService rather than forcing one account to know about another. Ensure domain services remain stateless and express intent through method names derived from the ubiquitous language.
When should you refactor legacy PHP code toward Domain-Driven Design?
Not every PHP project needs DDD. Applying it prematurely adds indirection without value. Refactor toward Domain-Driven Design for PHP applications when you observe specific pain signals indicating accidental complexity has overtaken essential complexity.
| Signal | Legacy Symptom | DDD Remedy |
|---|---|---|
| God Classes | Controllers/services exceeding 500 lines with mixed concerns | Extract entities, value objects, and domain services |
| Primitive Obsession | Strings/arrays passed everywhere, validated repeatedly | Introduce self-validating value objects |
| Anemic Domain | Models are getters/setters only; all logic in services | Move behavior into entities; services orchestrate only |
| Leaky Abstractions | Database columns exposed directly to views/APIs | Define explicit read models and DTOs per context |
| Integration Spaghetti | Direct calls to third-party APIs scattered everywhere | Implement Anti-Corruption Layer with adapters |
Adopt the strangler fig pattern for refactoring. Identify one bounded context with high change frequency but low coupling. Isolate it behind a clean interface, reimplement using DDD, then gradually redirect traffic. Never attempt a big-bang rewrite. Teams in Nepal working with legacy e-commerce or government systems often find the Billing or Reporting contexts ideal starting points due to their well-defined regulatory boundaries.
Testing domain logic in isolation
The greatest ROI of DDD is fast, reliable unit testing. Because domain objects lack framework dependencies, tests run in milliseconds without bootstrapping Laravel or connecting to databases. Write specification-style tests that verify business rules, not implementation details.
<?php
use PHPUnit\Framework\TestCase;
use App\BillingContext\Domain\ValueObject\Money;
final class MoneyTest extends TestCase
{
public function testAddingSameCurrencyReturnsNewInstance(): void
{
$a = new Money(1000, 'NPR');
$b = new Money(500, 'NPR');
$result = $a->add($b);
$this->assertEquals(new Money(1500, 'NPR'), $result);
$this->assertNotSame($a, $result); // Immutability check
}
public function testAddingDifferentCurrenciesThrows(): void
{
$this->expectException(\DomainException::class);
(new Money(1000, 'NPR'))->add(new Money(500, 'USD'));
}
} If your test requires mocking a facade or setting up a database fixture to verify a business rule, the logic resides in the wrong layer. Move it inward. Consult Laravel testing with Pest in CI/CD for integrating these pure domain tests into automated pipelines alongside integration tests.
Practical next steps for adopting DDD in PHP
Begin by mapping your current system’s implicit boundaries. Draw context maps on whiteboards with stakeholders before touching IDEs. Identify one subdomain with high business value and moderate complexity as your pilot. Implement hexagonal architecture there while maintaining legacy integration through ACLs. Measure success by reduced cyclomatic complexity, faster test execution, and decreased time-to-change for business rule modifications.
Remember that DDD is a long-term investment, not a sprint. Expect initial slowdown as the team learns ubiquitous language and modeling discipline. The payoff arrives when onboarding new developers takes days instead of weeks, and when regulatory audits pass because compliance logic is explicitly modeled rather than implicitly scattered. If your team struggles with translating business requirements into clean PHP architectures, reach out for architecture consulting tailored to your organization’s maturity level and domain complexity.