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

Rich entities in PHP: Keep your business rules and invariants close to your domain model 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.

Mar 21, 2025 - 15:15
 0
Rich vs Anemic Entities in PHP: How to Structure Your Business Logic Right

Rich entities in PHP: Keep your business rules and invariants close to your domain model

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.



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.



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.