DEV Community

Cover image for Rich vs Anemic Entities in PHP with Doctrine: How to Structure Your Business Logic Right
Mykola Vantukh
Mykola Vantukh

Posted on • Edited on

2 1 2

Rich vs Anemic Entities in PHP with Doctrine: How to Structure Your Business Logic Right

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode
  • 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());
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

👉 Connect with me on LinkedIn

AWS Q Developer image

Your AI Code Assistant

Implement features, document your code, or refactor your projects.
Built to handle large projects, Amazon Q Developer works alongside you from idea to production code.

Get started free in your IDE

Top comments (8)

Collapse
 
xwero profile image
david duymelinck • Edited

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 a Debit or a Credit value object. And a separate hasPaidInFull 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 the Order is saved.

Collapse
 
mykola_vantukh profile image
Mykola Vantukh

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

Collapse
 
xwero profile image
david duymelinck

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.

Thread Thread
 
mykola_vantukh profile image
Mykola Vantukh • Edited

Thanks, I will take it into account in the future. This is my first article, so I truly appreciate the criticism)

Collapse
 
nothingimportant34 profile image
No Name

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).

Collapse
 
mykola_vantukh profile image
Mykola Vantukh

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

Collapse
 
nothingimportant34 profile image
No Name

Thank you for the answer and explanation

Some comments may only be visible to logged-in visitors. Sign in to view all comments.

AWS Security LIVE!

Join us for AWS Security LIVE!

Discover the future of cloud security. Tune in live for trends, tips, and solutions from AWS and AWS Partners.

Learn More

👋 Kindness is contagious

If this article connected with you, consider tapping ❤️ or leaving a brief comment to share your thoughts!

Okay