
September 08, 2026
12 min read
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.
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.
| Signal | CRUD-first approach | DDD approach |
|---|---|---|
| Rule complexity | Few validations, stable logic | Many invariants, frequent policy changes |
| Team size | 1–2 developers, short deadline | 3+ devs or long maintenance horizon |
| Domain language | Generic tables and columns | Stakeholders use precise terms daily |
| Failure cost | Low — fix data manually | High — legal, financial, or ops impact |
| Integration surface | Single app, one database | APIs, 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
- HTTP controller receives input and builds a command or DTO.
- Application service loads aggregates through repository interfaces.
- Domain objects enforce invariants and emit domain events.
- Repository implementation persists state via Eloquent or Doctrine.
- 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.
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.
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.
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
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.

