Doctrine-Based Rich Entities in PHP: A Better Way to Model Business Logic
When building complex business applications, I follow one core principle:
Keep domain logic close to the domain, not spread across the application.
Instead of treating entities like simple data containers (the common Anemic Entities), I design Rich Entities — objects that encapsulate both state and behavior.
This approach allows me to:
- Enforce business rules directly inside the model
- Protect domain invariants
- Build scalable, expressive, testable codebases
Let me show you exactly how I apply this in real-world PHP apps, using Doctrine ORM.
The Anemic Entity
In many PHP applications, entities are treated as plain data holders, while all logic is pushed to service classes.
<?php
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
#[ORM\Entity]
#[ORM\Table(name: "orders")]
class Order
{
#[ORM\Id]
#[ORM\Column(type: "string")]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
private string $id;
#[ORM\Column(type: "integer")]
private int $customer_id;
#[ORM\Column(type: "decimal", precision: 10, scale: 2)]
private float $amount_to_pay;
#[ORM\Column(type: "datetime")]
private DateTime $created_at;
#[ORM\Column(type: "datetime", nullable: true)]
private ?DateTime $fully_paid_at = null;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(name: "customer_id", referencedColumnName: "id")]
private Customer $customer;
#[ORM\OneToMany(targetEntity: OrderPayment::class, mappedBy: "order", orphanRemoval: true, cascade: ["persist", "remove"])]
private Collection $payments;
public function __construct()
{
$this->payments = new ArrayCollection();
}
public function getId(): string
{
return $this->id;
}
public function getCustomerId(): int
{
return $this->customer_id;
}
public function getAmountToPay(): float
{
return $this->amount_to_pay;
}
public function getCreatedAt(): DateTime
{
return $this->created_at;
}
public function getFullyPaidAt(): ?DateTime
{
return $this->fully_paid_at;
}
public function getCustomer(): Customer
{
return $this->customer;
}
public function getPayments(): Collection
{
return $this->payments;
}
public function setCustomerId(int $customer_id): void
{
$this->customer_id = $customer_id;
}
public function setAmountToPay(float $amount_to_pay): void
{
$this->amount_to_pay = $amount_to_pay;
}
public function setCreatedAt(DateTime $created_at): void
{
$this->created_at = $created_at;
}
public function setFullyPaidAt(?DateTime $fully_paid_at): void
{
$this->fully_paid_at = $fully_paid_at;
}
public function setCustomer(Customer $customer): void
{
$this->customer = $customer;
}
public function setPayments(Collection $payments): void
{
$this->payments = $payments;
}
public function addPayment(OrderPayment $payment): void
{
$this->payments->add($payment);
}
}
This leads to code that:
- Is harder to test
- Violates encapsulation
- Spreads business rules across the application
- Encourages duplication and brittle architectures
This approach is commonly used in architectures that prefer behavior-less entities, where all business logic is intentionally placed in application services. While this can work in some cases, it often results in fragmented, harder-to-maintain systems.
Let’s fix that.
The Rich Entity
Here's a simplified example of how I structure an Order
aggregate using Doctrine ORM.
This is not just a database record — it's a business object with meaningful behavior.
<?php
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
#[ORM\Entity]
#[ORM\Table(name: "orders")]
class Order
{
#[ORM\Id]
#[ORM\Column(type: "string")]
#[ORM\GeneratedValue(strategy: "IDENTITY")]
protected string $id;
#[ORM\Column(type: "integer")]
protected int $customer_id;
#[ORM\Column(type: "decimal", precision: 10, scale: 2)]
protected float $amount_to_pay;
#[ORM\Column(type: "datetime")]
protected DateTime $created_at;
#[ORM\Column(type: "datetime", nullable: true)]
protected ?DateTime $fully_paid_at = null;
#[ORM\ManyToOne(targetEntity: Customer::class)]
#[ORM\JoinColumn(name: "customer_id", referencedColumnName: "id")]
private Customer $customer;
#[ORM\OneToMany(targetEntity: OrderPayment::class, mappedBy: "order", orphanRemoval: true, cascade: ["persist", "remove"])]
protected Collection $payments;
protected function __construct()
{
$this->payments = new ArrayCollection();
}
public static function create(Customer $customer, float $amountToPay): self
{
if ($amountToPay <= 0.0) {
throw new \InvalidArgumentException("Order amount must be greater than zero.");
}
$order = new self();
$order->customer = $customer;
$order->amount_to_pay = $amountToPay;
$order->created_at = new DateTime();
return $order;
}
public function pay(float $amount): OrderPayment
{
if ($amount <= 0.0) {
throw new \InvalidArgumentException("Payment amount must be greater than zero.");
}
if ($this->isFullyPaid()) {
throw new \LogicException("Order has already been fully paid.");
}
if ($amount > $this->amount_to_pay) {
throw new \LogicException(
sprintf("Invalid payment amount. Remaining amount to pay: %.2f", $this->amount_to_pay)
);
}
$this->amount_to_pay -= $amount;
$payment = OrderPayment::create($this, $amount);
$this->payments->add($payment);
if ($this->amount_to_pay === 0.0) {
$this->fully_paid_at = new DateTime();
}
return $payment;
}
public function isFullyPaid(): bool
{
return $this->fully_paid_at instanceof DateTime;
}
public function getRemainingAmount(): float
{
return $this->amount_to_pay;
}
public function getFullyPaidAt(): ?DateTime
{
return $this->fully_paid_at;
}
public function getCustomer(): Customer
{
return $this->customer;
}
public function getPayments(): Collection
{
return $this->payments;
}
}
That’s it.
This design aligns well with SOLID principles:
- Single Responsibility Principle — entity governs its own rules
- Open/Closed Principle — you can extend without modifying internals
- Encapsulation — internal state is protected and validated
Why Not a Service Layer?
You could handle this logic through a service like:
class OrderPaymentService
{
public function pay(Order $order, float $amount): OrderPayment
{
if ($amount <= 0.0) {
throw new \InvalidArgumentException("Amount must be positive.");
}
if ($order->getFullyPaidAt() !== null) {
throw new \LogicException("Order already fully paid.");
}
$remainingAmount = $order->getAmountToPay();
if ($amount > $remainingAmount) {
throw new \LogicException("Overpayment not allowed.");
}
$order->setAmountToPay($remainingAmount - $amount);
$payment = OrderPayment::create($order, $amount);
$order->addPayment($payment); // this method still makes sense in anemic model
if ($order->getAmountToPay() === 0.0) {
$order->setFullyPaidAt(new \DateTime());
}
return $payment;
}
}
But this has several downsides:
- Business logic is disconnected from the domain model
- Logic gets duplicated and harder to test
- Encapsulation is weak — the entity exposes its internal state through public setters like $order->setAmountToPay(), which allows any external code to change critical values without enforcing domain rules or ensuring consistency. This can easily lead to invalid business state — for example, setting amountToPay to zero without marking the order as fully paid or registering a payment
- When you want to understand or change how an entity works, you must search for the service that “owns” its logic — like OrderPaymentService, OrderWorkflowManager, or some arbitrary class buried in the application layer. This leads to poor discoverability, tight coupling, and a fragile architecture where rules are duplicated or missed altogether. In contrast, Rich Entities encapsulate that behavior directly inside the model, so the code that changes entity state is co-located with the state itself — making the system more expressive, testable, and easier to reason about.
Rich Entities Are Easier to Test
Another practical benefit of Rich Entities is how much simpler and cleaner unit tests become.
Instead of testing service classes full of business logic, we can test behavior directly on the entity itself — with less boilerplate and better focus.
Testing a Rich Entity (Simple, Focused Unit Test)
public function test_order_can_be_fully_paid()
{
$customer = $this->createStub(Customer::class);
$order = Order::create($customer, 200.00);
$payment1 = $order->pay(120.00);
$payment2 = $order->pay(80.00);
$this->assertTrue($order->isFullyPaid());
$this->assertEquals(0.00, $order->getRemainingAmount());
$this->assertCount(2, $order->getPayments());
$this->assertInstanceOf(DateTime::class, $order->getFullyPaidAt());
}
- Simple, expressive, domain-focused test
- No mocks or infrastructure setup
- Verifies business behavior directly
Testing Service-Class-Based Logic (More Setup, More Fragile)
public function test_service_can_pay_order()
{
$customer = $this->createStub(Customer::class);
$order = Order::create($customer, 200.00);
$service = new OrderPaymentService();
$payment1 = $service->pay($order, 120.00);
$payment2 = $service->pay($order, 80.00);
$this->assertEquals(0.00, $order->getAmountToPay());
$this->assertCount(2, $order->getPayments());
$this->assertInstanceOf(DateTime::class, $order->getFullyPaidAt());
}
More indirection
- Business logic is harder to verify in isolation
- Still coupled to internal entity state (setAmountToPay, setFullyPaidAt, etc.)
By keeping your business rules inside your entities, you naturally get simpler tests, better coverage, and cleaner architecture — without needing heavy mocks or complex setups.
Why I Use Static create()
Methods
You’ll notice I don’t call new Order()
directly. Instead, I use:
Order::create($customer, $amount);
Why?
- Ensures entity is always created in a valid state
- Prevents broken, incomplete, or invalid objects
- Keeps initialization logic in one place
- Improves code readability and reliability
Conclusion
Designing rich entities is not just a stylistic choice — it’s a strategic architectural decision that impacts clarity, maintainability, and scalability of your codebase.
Rich models shine when:
- You have complex business rules and invariants to enforce
- You want to reduce duplication across services and layers
- You aim for clear, expressive APIs that reflect real-world actions (
$order->pay()
vs$orderService->payOrder($order, $amount)
)
That said, anemic models still have valid use cases:
- In CRUD-heavy applications with little domain complexity
- When you want to keep entities simple and defer all behavior to services
- In team environments where DDD is not a shared mindset or priority
And yes, services still matter — but ideally, they should orchestrate use cases, not own the business logic.
A thin service layer coordinating rich domain models is often the sweet spot:
- Services handle application flow and infrastructure concerns
- Entities handle core domain behavior and validation
So next time you’re building a feature, ask yourself:
“Is this a domain rule, or just a workflow orchestration?”
“Does this logic belong in a service — or should the entity own it?”
Use the right tool for the job — but always be intentional about where your business logic lives.
📬 Let’s Connect
If you enjoyed this article or want to chat about PHP architecture, domain-driven design, or interesting projects — feel free to reach out.
Top comments (8)
Maybe I'm too purist , but having an doctrine entity in the domain for me is too much coupling with the infrastructure.
Because you are still in a ORM mindset, I think you are missing that the amount not only can go down, but it can go up too because of bad pricing, problems with payment service, other valid reasons.
I would have an
amountChange
method that can have aDebit
or aCredit
value object. And a separatehasPaidInFull
method.As a sidenote, exceptions are not a good way to do validation. A better way is to return an Errors instance, that contains an array of error messages, that can be pushed to the interface. Also use an enum as the message instead of a string, that is easier for translations.
With php 8.4 we now have asymmetric property visibility, which makes getters a thing of the past while keeping the encapsulation of the properties.
On the
create
method I think in the example it is not needed. The validation can happen in the__construct
method, which initialises a valid instance.Adding the
created_at
property will backfire after the first time theOrder
is saved.Thanks a lot for the thoughtful feedback — really appreciate you taking the time to share it.
You’re totally right that from a more DDD-purist perspective, coupling domain models with ORM annotations is something to be cautious about. In this case though, my goal wasn’t to build a perfect DDD architecture, but to highlight the difference between anemic and rich entities in a clear and practical way. The examples are intentionally simplified to keep the focus on that core idea.
I also get your point about using the constructor instead of a static create() method — that’s a valid option, especially for simple models. Personally, I like static constructors because they make intent a bit more explicit and give more flexibility if the creation logic grows or needs to branch later on. But yeah — totally a matter of preference depending on the context.
That said, I really liked your point about modeling amount changes with value objects like Credit and Debit — that’s definitely a cleaner and more flexible approach in a more advanced domain. Same with enums for validation messages — great tip, especially when thinking about localization.
Appreciate the insights — you’ve given me a few ideas I might explore in a follow-up article
I understand that the main goal was to showcase rich models. And that information you showed perfectly clear.
It is the DDD part that is a bit murky. I know sometimes hybrids can be a valid solution, but when you want to teach something I find it best to put the things in the correct boxes.
I'm still making that same mistake, and I have to correct myself every time, that is why it jumps out to me.
Thanks, I will take it into account in the future. This is my first article, so I truly appreciate the criticism)
Hi! While I like this idea, what if you need to contact some third party vendor to process payment (such as Braintree). Or use any other service for that matter? You can't really inject it through constructor, you won't be instating it from scratch in the entity (I assume).
Hello. Calling Braintree and handling the result — should sit in an application service or use case class, not in the entity itself. The Order entity still owns the business rules (like validating the amount, checking if it’s already paid, etc.), but the service layer just orchestrates the process: call Braintree → if success → tell the entity to apply the state change. If I correctly understand your question
Thank you for the answer and explanation
Some comments may only be visible to logged-in visitors. Sign in to view all comments.