Domain-Driven Design for PHP Applications

Khimananda Oli 9 min read Web Development
Domain-Driven Design for PHP Applications

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.

Strategic Design: Bounded ContextsOrder ContextOrder, LineItem, CustomerIdStatus: Pending/Paid/ShippedInventory ContextProduct, StockLevel, WarehouseReservation LogicBilling ContextInvoice, Payment, TaxRuleLedger EntriesAnti-Corruption Layer (ACL)Translates between contexts via Events/DTOsPrevents leakage of foreign domain models
Figure 1: Strategic bounded contexts isolate domain logic; the Anti-Corruption Layer prevents model pollution across boundaries in Domain-Driven Design for PHP applications.

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 Customer or Invoice.
  • Value Object: No unique ID, immutable after creation, self-validating, represents descriptors like Money, EmailAddress, or Coordinate.
  • Primitive Obsession: Using strings/integers where value objects belong. Replace string $email with EmailAddress $email to 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.

Hexagonal Architecture (Ports & Adapters)DOMAIN COREEntities, Value ObjectsDomain ServicesINBOUND PORTS (Interfaces)OUTBOUND PORTS (Repository Interfaces)HTTP ControllerConsole CommandEvent SubscriberMySQL RepositoryRedis Cache AdapterExternal API ClientDRIVESDRIVEN BY
Figure 2: Hexagonal architecture isolates the domain core from infrastructure; inbound adapters drive use cases while outbound adapters implement repository interfaces.

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.

SignalLegacy SymptomDDD Remedy
God ClassesControllers/services exceeding 500 lines with mixed concernsExtract entities, value objects, and domain services
Primitive ObsessionStrings/arrays passed everywhere, validated repeatedlyIntroduce self-validating value objects
Anemic DomainModels are getters/setters only; all logic in servicesMove behavior into entities; services orchestrate only
Leaky AbstractionsDatabase columns exposed directly to views/APIsDefine explicit read models and DTOs per context
Integration SpaghettiDirect calls to third-party APIs scattered everywhereImplement 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.

Transformation: Anemic → Rich Domain ModelBEFORE: Anemic Modelclass Order extends Model{ get/set only, no behavior }public $status, $items...class OrderService{ 800 lines of procedural logic }if ($order->status == 'draft')...$order->status = 'placed';// Validation scattered everywhere// Framework coupled tightlyAFTER: Rich Domain Modelclass Order (Aggregate Root)private OrderStatus $status;+ place(): void+ cancel(reason): void+ calculateTotal(): Money{ Encapsulates rules & invariants }Application Service{ Thin orchestration only }$order->place();$repo->save($order);REFACTOR
Figure 3: Transitioning from anemic models to rich domain objects moves business logic inward, reducing service bloat and improving cohesion in Domain-Driven Design for PHP applications.

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.

Frequently Asked Questions

Yes, DDD adds significant complexity unsuitable for simple CRUD apps or prototypes. Reserve it for complex business logic where modeling accuracy outweighs development speed and maintenance overhead.

MVC organizes by technical layer while DDD structures code around business domains. Entities and value objects replace anemic models, ensuring business rules live in the domain layer rather than controllers or services.

Use PHP 8.4 or newer for readonly classes, enums, and fibers. These features reduce boilerplate for value objects and entities, making domain modeling more expressive and type-safe without external libraries.

Yes, start by extracting one bounded context into a dedicated namespace with proper value objects. Refactor adjacent code gradually, using anti-corruption layers to isolate new domain models from legacy infrastructure.

Consider php-ddd/building-blocks for base classes, laravel-doctrine/orm for rich entity mapping, and event-sourcing libraries like patchlevel/event-sourcing. Avoid heavy frameworks; prefer lightweight tools enforcing domain purity.

Value objects enforce validation at construction, preventing invalid state propagation. They eliminate primitive obsession, reducing injection risks and ensuring business invariants hold throughout the application lifecycle without defensive checks.

Expect ten to twenty percent overhead from object hydration and domain events. Mitigate with read models, CQRS separation, and OPcache preloading to offset abstraction costs in high-traffic endpoints.

Create top-level directories per context under src with isolated domain, application, and infrastructure layers. Share only published events or explicit contracts, never internal entities, to maintain context boundaries.

Not inherently, but requires careful mapping configuration. Use XML or attribute mappings separate from entities, avoid active record patterns, and implement repository interfaces to keep domain layer persistence-agnostic.

Unit test entities and value objects directly using PHPUnit. Mock repositories via interfaces, verify business rules through state transitions, and use in-memory implementations for integration tests within bounded contexts.

Apply CQRS when read and write models diverge significantly or query performance bottlenecks emerge. Separate command handlers from read projections, using dedicated query services optimized for specific UI requirements.

Creating anemic domains with logic in services, leaking infrastructure into domain layer, over-engineering simple contexts, and ignoring ubiquitous language. Focus on behavioral richness and team alignment before structural patterns.

Store domain events as immutable records, rebuilding entity state through replay. Use libraries like patchlevel/event-sourcing for snapshotting and projections, ensuring events capture business intent not technical changes.

Yes, but cold starts penalize heavy domain hydration. Optimize with lightweight containers, pre-warmed instances, and aggressive caching. Consider separating domain logic into microservices if invocation frequency justifies deployment complexity.

Track reduced bug rates in complex workflows, faster onboarding through ubiquitous language, decreased coupling between modules, and improved test coverage of business rules. Technical metrics matter less than business alignment gains.