Designing robust domain models requires strict encapsulation. Historically, implementing Domain-Driven Design (DDD) principles in PHP came with a heavy tax: endless boilerplate code, manual data validation loops, and exhaustive encapsulation setups that often drowned out the core business intent under layers of syntax noise.
With the release of PHP 8.4+, backend engineering has shifted toward engine-level modernity. By leveraging advanced features like Constructor Property Promotion, Asymmetric Visibility, and Property Hooks alongside proper interface boundaries, you can build elegant, highly performant, and resilient domain layers.
The central premise of DDD is that the core business logic (the Domain Layer) should be entirely separated from technical details like database engines, HTTP frameworks, third-party APIs, or queuing systems (the Infrastructure Layer).
The domain layer must remain pure, predictable, and unaware of how its data is persisted or transmitted. Infrastructure boundaries are maintained using strict type safety, dependency inversion, and standard structural definitions.
In legacy applications, models were often "anemic"—mere data-holding structures consisting of private properties with public getters and setters. True domain models are Rich Domain Models, which actively enforce invariants (business rules) and manage their state internally.
Previously, if you wanted a property to be publically readable but privately writable, you had to write custom getter methods. PHP 8.4's Asymmetric Visibility (public private(set)) natively eliminates this tradeoff between structural safety and boilerplate overload.
Below is a production-grade representation of an enterprise Order Entity. It tracks lifecycle and guards its state using public private(set) constructs:
1namespace Domain\Order; 2 3use Domain\Order\ValueObject\OrderId; 4use Domain\Order\ValueObject\Money; 5use Domain\Order\ValueObject\OrderStatus; 6use Domain\Order\Exception\InvalidStatusTransitionException; 7 8class Order 9{10 // Asymmetric visibility permits public read access, but restricts write operations exclusively to this class instance11 public private(set) OrderStatus $status;12 public private(set) ?\DateTimeImmutable $fulfilledAt = null;13 14 public function __construct(15 public readonly OrderId $id,16 public readonly Money $total,17 OrderStatus $status = OrderStatus::PENDING18 ) {19 $this->status = $status;20 }21 22 /**23 * Enforces core business invariants during status changes24 */25 public function fulfill(): void26 {27 if ($this->status !== OrderStatus::PAID) {28 throw new InvalidStatusTransitionException("Only PAID orders can be fulfilled.");29 }30 31 $this->status = OrderStatus::FULFILLED;32 $this->fulfilledAt = new \DateTimeImmutable();33 }34}
Value Objects are defined not by a unique identifier, but by their attributes. They represent descriptive aspects of the domain (such as Money, Email, or Address) and are inherently immutable.
In modern PHP, we can combine Constructor Property Promotion with Property Hooks to build lightweight, self-validating, and optimized Value Objects. Property Hooks allow us to define intercepting logic natively within the property declaration.
1namespace Domain\Order\ValueObject; 2 3use InvalidArgumentException; 4 5final readonly class Money 6{ 7 /** 8 * Property Hooks capture assignments and run native validation rules seamlessly 9 */10 public int $amount {11 set {12 if ($value < 0) {13 throw new InvalidArgumentException("Monetary amounts cannot be negative.");14 }15 $this->amount = $value;16 }17 }18 19 public function __construct(20 int $amount,21 public string $currency = 'USD'22 ) {23 $this->amount = $amount; // Triggers the engine-level 'set' hook validation natively24 }25 26 public function add(Money $other): self27 {28 if ($this->currency !== $other->currency) {29 throw new InvalidArgumentException("Currency mismatch.");30 }31 32 return new self($this->amount + $other->amount, $this->currency);33 }34}
Massive Memory & Performance Optimizations: By moving property parsing and validation rules out of userland PHP framework loops and into native engine interception pathways, execution layers operate significantly faster and save memory.
Elimination of Static Noise: The codebase remains highly expressive. Your classes describe domain intent rather than language syntax constraints.
To keep infrastructure details away from business logic, the domain layer defines how it wants data fetched via an Interface. The infrastructure layer then fulfills that request through a concrete class implementation. This is the Repository Pattern.
The domain layers deal strictly with Entities and Value Objects, completely unaware of whether SQL, NoSQL, or local memory arrays are handling data underneath.
1namespace Domain\Order\Repository; 2 3use Domain\Order\Order; 4use Domain\Order\ValueObject\OrderId; 5 6interface OrderRepositoryInterface 7{ 8 public function findById(OrderId $id): ?Order; 9 public function save(Order $order): void;10}
Here, the infrastructure layer hooks into actual third-party tools (like Doctrine ORM, Eloquent, or raw PDO) to map raw storage arrays to domain entities.
1namespace Infrastructure\Persistence\Doctrine; 2 3use Domain\Order\Repository\OrderRepositoryInterface; 4use Domain\Order\Order; 5use Domain\Order\ValueObject\OrderId; 6use Doctrine\ORM\EntityManagerInterface; 7 8final readonly class DoctrineOrderRepository implements OrderRepositoryInterface 9{10 public function __construct(11 private EntityManagerInterface $entityManager12 ) {}13 14 public function findById(OrderId $id): ?Order15 {16 return $this->entityManager->find(Order::class, $id->toString());17 }18 19 public function save(Order $order): void20 {21 $this->entityManager->persist($order);22 $this->entityManager->flush();23 }24}
Writing strict domain models means zero tolerance for runtime errors. In cloud-native enterprise environments, modern static analysis tools like PHPStan and Psalm act as essential parts of your architecture pipeline.
Modern static analysis engines natively understand PHP 8.4 asymmetric visibility and property hooks. Running these tools at 'level max' prevents unexpected state modification, type conflicts, or boundary leaks before the code ever leaves a local environment or hits a CI/CD pipeline.
By enforcing strict type safety natively, the underlying Zend Engine operates with predictable structures, creating an enterprise application layer optimized for modern backend engineering.
PHP.net: PHP 8.4 Release Announcement
PHP Internals RFC: PHP RFC: Asymmetric Visibility
PHP Internals RFC: PHP RFC: Property Hooks
Zend Blog: A Guide to PHP 8.4 Property Hooks
Static Analysis Industry Standards: PHPStan Documentation and Psalm Main Documentation
About the author
Darren Odden is a seasoned software developer and web architect specializing in the modern PHP and Laravel ecosystems, where he designs elegant APIs and robust web applications. As a dedicated tech advocate, he focuses on community building and championing clean, modern development practices. When he isn’t diving into code or fine-tuning tech stacks, Darren balances his digital life by hitting the open road with his family in their travel trailer, camping, solving crossword puzzles, and immersing himself in the rich subcultures of classic hip-hop and vintage graffiti art.
Built for Developers, by Developers
Join the movement and discover why modern PHP is the sophisticated choice for elegant, high-scale applications in 2026.
Reach Us
Santa Cruz, CA 95062