Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Domain-Driven Design for PHP Applications

By Kokil Thapa | Last reviewed: September 2026

Complex PHP systems fail when the code structure drifts from the business language. Domain-Driven Design for PHP Applications fixes that by putting rules, invariants, and workflows at the centre of your architecture. On a legal-tech portal or a multi-step booking platform, the real cost is not CRUD screens. It is misunderstood domain logic buried inside controllers and fat Eloquent models. This guide shows how to apply DDD tactically in enterprise PHP applications without turning a small Laravel site into a research project.

What is Domain-Driven Design for PHP Applications?

Domain-Driven Design (DDD) is a way to align software structure with business vocabulary. Eric Evans introduced the approach in 2003. The core idea still holds in 2026: your code should speak the same language as product owners, lawyers, accountants, or operations staff.

In PHP, DDD is not a Composer package you install once. It is a set of modelling habits plus a layered layout. The domain layer holds pure business logic. Application services coordinate use cases. Infrastructure talks to MySQL 9.7, Redis 8.10, payment gateways, and email. The presentation layer handles HTTP, CLI, or queue workers.

PHP 8.3 and 8.5 give you strong tools for this style. Readonly classes, enums, typed properties, and constructor promotion make value objects cheap to write and hard to misuse. I've used this pattern on production Laravel applications where booking rules, document states, and fee calculations change more often than the UI.

DDD in PHP: Two LevelsStrategic DesignBounded contextsUbiquitous languageContext mapsTactical DesignEntities and value objectsAggregatesDomain eventsPHP Application LayersPresentation → Application → Domain → InfrastructureLaravel 13, Symfony 8.1, plain PHP
Strategic DDD defines boundaries and shared language; tactical patterns live inside each bounded context in your PHP codebase.

Strategic vs tactical patterns

Strategic design answers where boundaries sit. A law firm portal might split Case Intake, Billing, and Document Management into separate bounded contexts. Each context gets its own models and glossary. Tactical design answers how you express rules inside one context using entities, value objects, aggregates, repositories, and domain events.

Most PHP teams should start tactical inside one context. Splitting microservices too early creates integration pain. A monolithic Laravel 12 or 13 app with clear domain folders often ships faster for Nepal SMB clients with small teams.

When should you use Domain-Driven Design in a PHP project?

Not every WordPress brochure site needs DDD. Use it when business rules are non-trivial, change often, and cost money when wrong. Payment reconciliation, court fee logic, multi-step approvals, and inventory allocation are typical triggers.

Skip full DDD when you have simple CRUD, a short lifespan, or a team that will not maintain layers. A landing page with a contact form does not need an aggregate root. A custom eCommerce cart with delivery zones, partial refunds, and gateway callbacks often does.

SignalCRUD-first approachDDD approach
Rule complexityFew validations, stable logicMany invariants, frequent policy changes
Team size1–2 developers, short deadline3+ devs or long maintenance horizon
Domain languageGeneric tables and columnsStakeholders use precise terms daily
Failure costLow — fix data manuallyHigh — legal, financial, or ops impact
Integration surfaceSingle app, one databaseAPIs, webhooks, external workflows

On projects like trek booking systems, departure capacity, supplier payouts, and deposit rules belong in the domain layer. Putting that logic in Blade controllers creates bugs that only appear after Dashain booking spikes.

How do you structure a Laravel or Symfony app with DDD layers?

A practical folder layout keeps framework code at the edges. The domain never imports Illuminate or Symfony HttpFoundation classes. That rule is strict. Break it once and the layer bleeds.

Below is a Laravel 13 layout I have used on client projects. Symfony 8.1 maps cleanly to the same idea with bundles or single-app src/ trees.

app/
  Domain/
    Booking/
      Booking.php              // aggregate root
      BookingId.php            // value object
      BookingStatus.php        // enum
      BookingRepository.php    // interface
      Events/
        BookingConfirmed.php
      Exceptions/
        CannotConfirmBooking.php
  Application/
    Booking/
      ConfirmBookingHandler.php
      ConfirmBookingCommand.php
  Infrastructure/
    Persistence/
      EloquentBookingRepository.php
      EloquentBookingMapper.php
  Http/
    Controllers/
      BookingController.php

Request flow through layers

  1. HTTP controller receives input and builds a command or DTO.
  2. Application service loads aggregates through repository interfaces.
  3. Domain objects enforce invariants and emit domain events.
  4. Repository implementation persists state via Eloquent or Doctrine.
  5. Event listeners handle side effects: mail, SMS, webhooks, cache.

This mirrors event-driven Laravel patterns, but domain events are defined in Domain/, not in app/Events with framework traits mixed in.

PHP DDD Request FlowHTTPControllerApplicationUse caseDomainRulesInfraDB / APIDependency direction: inward onlyDomain defines repository interfacesInfrastructure implements themNo Eloquent in domain classesPHP 8.3+ readonly value objects
Controllers stay thin; domain rules sit at the centre; infrastructure adapts Laravel or Symfony to your ports.

Symfony vs Laravel placement

Symfony 8.1 already encourages separation through autowired services and single-action controllers. Doctrine entities often become persistence models while rich domain objects live in Domain/. Laravel teams sometimes resist extra folders. The payoff appears during refactors when Rector and PHPStan level 9 can analyse pure domain code without bootstrapping the framework.

Register bindings in a service provider or Symfony services.yaml:

// Laravel AppServiceProvider
$this->app->bind(
    BookingRepository::class,
    EloquentBookingRepository::class
);

How do you model aggregates, entities, and value objects in PHP?

Tactical DDD is where PHP shines. Value objects wrap primitives so invalid states become unrepresentable. Entities carry identity across time. Aggregates group entities under one consistency boundary.

Value objects with readonly classes

<?php
declare(strict_types=1);

namespace App\Domain\Shared;

final readonly class Money
{
    public function __construct(
        public int $amountMinor,
        public string $currency,
    ) {
        if ($amountMinor < 0) {
            throw new \InvalidArgumentException('Amount cannot be negative.');
        }
        if (strlen($currency) !== 3) {
            throw new \InvalidArgumentException('Currency must be ISO 4217.');
        }
    }

    public function add(Money $other): self
    {
        if ($this->currency !== $other->currency) {
            throw new \DomainException('Currency mismatch.');
        }
        return new self($this->amountMinor + $other->amountMinor, $this->currency);
    }
}

Pair money value objects with a court fee calculator on legal portals only at the UI layer. Domain code should not call HTTP tools. It should receive already-normalised inputs or use its own fee policy objects.

Entities and aggregate roots

An entity is equal when its ID matches, not when all fields match. The aggregate root is the only entry point for changes inside the boundary. External code must not mutate child entities directly.

<?php
declare(strict_types=1);

namespace App\Domain\Booking;

final class Booking
{
    private function __construct(
        private BookingId $id,
        private BookingStatus $status,
        /** @var list<LineItem> */
        private array $items,
    ) {}

    public static function draft(BookingId $id): self
    {
        return new self($id, BookingStatus::Draft, []);
    }

    public function addItem(ProductRef $product, int $qty): void
    {
        if ($this->status !== BookingStatus::Draft) {
            throw new CannotModifyBooking('Only draft bookings accept items.');
        }
        $this->items[] = LineItem::create($product, $qty);
    }

    public function confirm(): void
    {
        if ($this->items === []) {
            throw new CannotConfirmBooking('Empty booking.');
        }
        $this->status = BookingStatus::Confirmed;
    }
}

Use PHP enums for status fields instead of magic strings. Domain exceptions should be specific. Catch them at the application boundary and map to HTTP responses or logged queue failures.

Aggregate BoundaryOrder AggregateOrder RootOrderIdLineIteminternalAddressvalue objExternal accessvia root methods onlyOne transactionper aggregate save
The aggregate root controls mutations; child entities stay encapsulated; one persistence transaction commits the whole cluster.

Repositories and domain events

Repositories load and save aggregates. They hide SQL, Eloquent, or Doctrine details. Keep queries that power reports in read models or dedicated query services. Do not bloated repository interfaces with twenty report methods.

Domain events announce facts: BookingConfirmed, PaymentCaptured, DocumentNotarized. Dispatch them after the aggregate state is valid. For reliable delivery to external systems, follow webhook reliability patterns in infrastructure listeners.

What are common mistakes when applying DDD to PHP apps?

The biggest mistake is anaemic domain models. Developers create entity classes with getters and setters, then put all logic in service classes named SomethingManager. That is layered CRUD, not DDD. Behaviour belongs on the aggregate when the rule is about that object's consistency.

Second mistake: giant aggregates. If saving an Order also locks Inventory, Billing, and Shipping in one transaction, you will hit contention. Split bounded contexts or use eventual consistency with domain events and idempotent handlers.

Third mistake: letting Eloquent models become domain entities. Active Record mixes persistence with behaviour. Map between them explicitly:

final class EloquentBookingRepository implements BookingRepository
{
    public function save(Booking $booking): void
    {
        $row = BookingModel::query()->find($booking->id()->value())
            ?? new BookingModel();

        $row->id = $booking->id()->value();
        $row->status = $booking->status()->value;
        $row->save();
    }
}

Fourth mistake: skipping collaboration with stakeholders. DDD without workshops produces fancy folders and the same misunderstood rules. Schedule short glossary sessions. Capture terms in docblocks or a shared wiki. On legal-tech work, Nepali and English terms must align before you name classes.

Fifth mistake: ignoring tests at the domain level. Pure PHP unit tests run in milliseconds. Test every invariant without touching the database. Use testing and optimisation practices as part of delivery, not as a post-launch patch.

DDD Adoption DecisionComplex rules?No → CRUD OKWordPress, simple CMSYes → Next checkLong-lived app?Tactical DDDDomain + app layersFull strategic DDDMultiple contextsStart small: one bounded context, extract later
Use this decision path before introducing bounded contexts, microservices, or heavy mapping layers into a PHP codebase.

How does Domain-Driven Design work with Eloquent and Doctrine?

Both ORMs are infrastructure concerns. Doctrine can map directly to rich entities, but many teams still prefer separate domain and persistence models for clarity. Eloquent's Active Record pattern pushes the other way. Explicit mappers add files but reduce coupling.

For read-heavy screens, combine write models with CQRS-lite query objects. Eloquent is fine for admin dashboards and exports. Domain aggregates handle commands that change state. This split avoids loading full object graphs for every listing page.

Indexing and fetch strategy still matter. A well-modelled domain with N+1 queries will still fail under load. Read MySQL index design and advanced Eloquent techniques alongside your DDD rollout. Schema design mistakes become expensive once aggregates map to many tables. Review common schema mistakes before migrations freeze.

JSON columns for flexible attributes belong in infrastructure DTOs, not core domain types. If you serialize domain objects directly, read PHP JSON handling guidance and never expose unsafe unserialize paths on persisted blobs.

Anti-corruption layers for integrations

Payment gateways, SMS providers, and government APIs speak their own dialect. Wrap them in an anti-corruption layer—a small adapter module that translates external payloads into domain value objects. I've applied this on production apps integrating eSewa, Khalti, and Stripe callbacks. The domain knows PaymentReference, not gateway POST fields.

For public APIs built on your domain, keep HTTP versioning outside the core. Your API layer should translate resources while application services stay stable. See SDK design principles for outward-facing contracts.

Official references help when onboarding teammates. Evans's strategic and tactical definitions remain the source vocabulary on domainlanguage.com. The PHP language documents readonly classes and enums on php.net. Laravel's service container and Symfony's service autowiring docs explain how to wire interfaces to implementations without coupling domain code to the framework.

Key Takeaways

  • Start with tactical DDD in one bounded context before splitting services or microservices.
  • Keep domain classes framework-free; use readonly value objects and enums in PHP 8.3+.
  • Let aggregate roots enforce invariants; repositories persist whole consistency boundaries.
  • Map Eloquent or Doctrine models explicitly instead of treating Active Record as domain logic.
  • Combine domain unit tests with query-side optimisation for read-heavy Laravel and Symfony apps.
  • Use anti-corruption layers around payment and third-party APIs so gateway shapes never leak inward.

People Also Ask

Is Domain-Driven Design overkill for Laravel projects?

It can be. Simple admin panels and marketing sites should stay thin. Laravel 13 works well with DDD when booking flows, billing rules, or compliance states change often and bad data has real cost. Apply layers where complexity lives, not everywhere by default.

Do you need Doctrine to do DDD in PHP?

No. Doctrine fits naturally, but Laravel plus explicit mappers works fine. The requirement is separating domain logic from persistence, not a specific ORM. Many production Laravel apps use Eloquent behind repository implementations successfully.

What is the difference between a domain event and a Laravel event?

A domain event expresses a business fact inside your ubiquitous language. It should not depend on Illuminate contracts. Laravel events are framework infrastructure. Dispatch domain events from application services and map them to queue jobs or notifications at the edge.

How big should a bounded context be in a monolith?

Big enough to cover one coherent business capability, small enough that a developer can explain its glossary in one meeting. Billing, catalog, and identity are usually separate contexts. Shared kernel code should stay minimal to avoid hidden coupling.

Ship domain logic that survives the next refactor

Domain-Driven Design for PHP Applications pays off when your stakeholders already think in workflows, fees, approvals, and statuses—not database tables. Start with one context, model value objects and aggregates in plain PHP, and push Laravel or Symfony to the borders. That is how you keep a client portal or custom platform maintainable after the original developer moves on.

If you are planning a complex Laravel or Symfony build and want the architecture done right from day one, review the custom software development approach or browse the portfolio for similar work. Need help untangling fat models in an existing app? Contact us with your current folder structure and main workflow—we can map a practical DDD migration path without a rewrite fantasy.

Frequently Asked Questions

DDD aligns PHP code structure with business vocabulary. The domain layer holds pure rules; application, infrastructure, and presentation layers handle use cases, MySQL, Redis, and HTTP.

Use DDD when business rules are non-trivial, change often, and mistakes cost money—payment reconciliation, court fee logic, multi-step approvals, or inventory allocation. Skip it for simple CRUD, short-lived sites, or teams that will not maintain layers. A contact-form landing page does not need aggregates; a custom cart with delivery zones, partial refunds, and gateway callbacks often does. On trek booking systems I have worked on, departure capacity, supplier payouts, and deposit rules belong in the domain layer, not Blade controllers where bugs surface only after seasonal spikes.

Often yes for simple admin panels and marketing sites. Laravel 13 suits DDD when booking flows, billing rules, or compliance states change often and bad data has real cost.

No. Doctrine fits naturally, but Laravel with explicit mappers behind repository interfaces works fine. The requirement is separating domain logic from persistence, not a specific ORM.

Keep framework code at the edges under folders like Domain/, Application/, Infrastructure/, and Http/Controllers/. The domain never imports Illuminate or Symfony HttpFoundation classes—that rule is strict. HTTP controllers build commands; application services load aggregates through repository interfaces; domain objects enforce invariants and emit events; Eloquent or Doctrine implementations persist state. Register interface bindings in a Laravel service provider or Symfony services.yaml, for example binding BookingRepository to EloquentBookingRepository. Symfony 8.1 maps cleanly via bundles or a single src/ tree; Laravel 13 teams gain refactors where PHPStan level 9 can analyse pure domain code without bootstrapping the framework.

Strategic DDD defines where boundaries sit and establishes shared language across the organisation. A law firm portal might split Case Intake, Billing, and Document Management into separate bounded contexts, each with its own models and glossary. Tactical DDD expresses rules inside one context using entities, value objects, aggregates, repositories, and domain events. Most PHP teams should start tactical inside a single context. Splitting microservices too early creates integration pain, whereas a monolithic Laravel 12 or 13 app with clear domain folders often ships faster for small teams maintaining long-lived business software.

Value objects wrap primitives with readonly classes so invalid states become unrepresentable—Money validates minor units and ISO currency before allowing addition. Entities are equal by identity, not field equality. The aggregate root is the sole entry point for changes; external code must not mutate child entities directly. Use PHP enums for status instead of magic strings, and throw specific domain exceptions like CannotConfirmBooking. Behaviour such as addItem or confirm lives on the aggregate when the rule protects that object's consistency. One persistence transaction should commit the whole cluster under the root.

The biggest is anaemic domain models—getters and setters on entities with logic parked in SomethingManager services, which is layered CRUD, not DDD. Second is giant aggregates that lock Inventory, Billing, and Shipping in one transaction, causing contention; split contexts or use domain events with idempotent handlers. Third is treating Eloquent models as domain entities instead of mapping explicitly through repositories. Fourth is skipping stakeholder glossary work, which produces fancy folders with the same misunderstood rules—Nepali and English terms must align on legal-tech portals. Fifth is ignoring fast domain-level unit tests that validate invariants without touching the database.

Both ORMs are infrastructure concerns. Doctrine can map to rich entities, but many teams keep separate domain and persistence models for clarity. Eloquent's Active Record pushes the other way, so explicit mappers add files but reduce coupling. Combine write-side aggregates with CQRS-lite query objects for read-heavy admin dashboards and exports—domain aggregates handle commands that change state without loading full object graphs on every listing. Indexing still matters; a well-modelled domain with N+1 queries fails under load. JSON columns for flexible attributes belong in infrastructure DTOs, not core domain types you serialize unsafely.

A domain event expresses a business fact in your ubiquitous language—BookingConfirmed or PaymentCaptured—and must not depend on Illuminate contracts. Laravel events are framework infrastructure for queues, mail, and notifications. Dispatch domain events from application services after aggregate state is valid, then map them to queue jobs, SMS, or webhooks in infrastructure listeners. Keep domain events defined under Domain/, not mixed into app/Events with framework traits, so the core stays portable and testable without bootstrapping Laravel.

An anti-corruption layer is a small adapter module that translates external payloads into domain value objects so third-party dialects never leak inward. Payment gateways, SMS providers, and government APIs speak their own field names and shapes. On production apps integrating eSewa, Khalti, and Stripe callbacks, the domain should know PaymentReference, not raw gateway POST fields. For public APIs built on your domain, keep HTTP versioning outside the core—the API layer translates resources while application services stay stable across contract changes.

No. Active Record mixes persistence with behaviour, which breaks the rule that domain code stays framework-free. Treat Eloquent models as infrastructure rows loaded and saved through repository implementations that map to rich domain aggregates. A repository finds or creates the Eloquent row, copies id and status from the aggregate, and saves—never the reverse during business operations. This separation lets you refactor booking or billing rules in pure PHP while swapping persistence details later without rewriting invariants embedded in model hooks or global scopes.

Yes, and for many projects that is the right starting point. The article recommends tactical DDD inside one bounded context before splitting services. A monolithic Laravel 12 or 13 application with Domain/, Application/, and Infrastructure/ folders often ships faster for Nepal SMB clients with small teams than premature microservices. Clear layer boundaries give you most maintainability gains without distributed integration pain. Split bounded contexts or extract services only when team size, deployment independence, or scale genuinely demands it—not because DDD requires separate deployables from day one.

Framework imports couple business rules to HTTP, container, and ORM lifecycles, making pure unit tests slow or impossible and blocking static analysis without full bootstrap. The article treats this as a strict rule: break it once and the layer bleeds. Domain code should receive normalised inputs and express fee policies or booking states on its own terms. Infrastructure adapts Laravel 13 or Symfony 8.1 to your ports through repositories, mappers, and listeners at the outer edge, preserving a centre that speaks product language and survives framework upgrades.

Repositories load and save whole aggregates through interfaces declared in the domain layer while hiding SQL, Eloquent, or Doctrine details in infrastructure implementations. Bind the interface to EloquentBookingRepository or a Doctrine equivalent in your service provider. Keep report queries and dashboard listings in read models or dedicated query services—do not bloat repository interfaces with dozens of reporting methods. Saves should persist the entire consistency boundary under the aggregate root in one transaction. Queries that only read data can use Eloquent directly on the infrastructure side without violating DDD when writes stay aggregate-driven.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: