- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: System Design Pocket Guide: Fundamentals
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Open the biggest "service" file in your PHP codebase. The one everyone on the team mutters about. OrderService.php, UserManager.php, BookingHandler.php. Every team has one.
Count the public methods. Count the lines. Count the constructor dependencies.
Now open the entity it operates on. Order.php, User.php, Booking.php. Count the methods that aren't getters or setters.
If the service has thirty methods and the entity has zero, you have the most common architecture mistake in PHP, and nobody on your team has called it out in a PR review because the framework's make:model generator has been training you to write this shape since the day you opened a Laravel tutorial.
This is the anemic domain with fat services antipattern. Martin Fowler named it in 2003. Two decades later, every PHP framework's generators still produce it by default, and most senior engineers still treat the resulting OrderService as "fine."
It's not fine. Let's look at why, and what to do instead.
The shape
Here's the entity:
<?php
namespace App\Domain;
final class Order
{
private int $id;
private string $status;
private int $totalCents;
private ?\DateTimeImmutable $placedAt = null;
private ?\DateTimeImmutable $cancelledAt = null;
private ?string $cancellationReason = null;
public function getId(): int { return $this->id; }
public function setId(int $id): void { $this->id = $id; }
public function getStatus(): string { return $this->status; }
public function setStatus(string $status): void { $this->status = $status; }
public function getTotalCents(): int { return $this->totalCents; }
public function setTotalCents(int $cents): void { $this->totalCents = $cents; }
public function getPlacedAt(): ?\DateTimeImmutable { return $this->placedAt; }
public function setPlacedAt(?\DateTimeImmutable $t): void { $this->placedAt = $t; }
public function getCancelledAt(): ?\DateTimeImmutable { return $this->cancelledAt; }
public function setCancelledAt(?\DateTimeImmutable $t): void { $this->cancelledAt = $t; }
public function getCancellationReason(): ?string { return $this->cancellationReason; }
public function setCancellationReason(?string $r): void { $this->cancellationReason = $r; }
}
Twelve methods. Six getters, six setters. No constructor. No rules. No invariants. You can construct an Order with no ID, no status, no total, in any state you want, and the class will hand it back to you cheerfully.
Here is the service:
<?php
namespace App\Services;
final class OrderService
{
public function __construct(
private OrderRepository $orders,
private RefundGateway $refunds,
private EventDispatcher $events,
private MailerInterface $mailer,
private LoggerInterface $logger,
private Clock $clock,
) {}
public function cancelOrder(int $orderId, ?string $reason): void
{
$order = $this->orders->findById($orderId);
if ($order === null) {
throw new OrderNotFound($orderId);
}
if ($order->getStatus() === 'shipped') {
throw new \DomainException('Cannot cancel a shipped order.');
}
if ($order->getStatus() === 'cancelled') {
return;
}
$order->setStatus('cancelled');
$order->setCancelledAt($this->clock->now());
$order->setCancellationReason($reason);
$this->orders->save($order);
$this->events->dispatch(new OrderCancelled($order->getId()));
}
public function fulfillOrder(int $orderId): void
{
$order = $this->orders->findById($orderId);
if ($order === null) {
throw new OrderNotFound($orderId);
}
if ($order->getStatus() !== 'paid') {
throw new \DomainException('Order must be paid before fulfilment.');
}
$order->setStatus('fulfilled');
$this->orders->save($order);
}
// ... twenty-two more methods like this
}
Read what cancelOrder actually does. It checks a string. It writes three fields. It saves. The "domain" entity contributed nothing. It was passed through setters and back to the repository. Every state rule lives in the service. The entity is a row dressed up as a class.
Why this is a bug, not a style choice
The team that wrote this code will defend it as a separation of concerns. "Entities are data. Services hold logic. That's clean."
It's not clean. It's exactly inverted. Here's what breaks.
The rules live in the wrong file. The rule "you can't cancel a shipped order" exists once, in OrderService::cancelOrder. The next person on the team writes AdminOrderService or BulkCancelJob or OrderApiController::cancel and either copies the check or forgets it. Six months later, production has a fulfilled order with status = 'cancelled', the postmortem blames a missing check in BulkCancelJob, and the real cause goes uncalled-out: the rule should never have been in a service in the first place.
Any caller can corrupt the state. $order->setStatus('banana') is valid PHP. $order->setCancelledAt(new \DateTimeImmutable()) without changing the status is valid PHP. $order->setTotalCents(-9999) is valid PHP. The class accepts every shape, including the ones that make no business sense, and the responsibility for refusing them shifts to whoever happens to call the setters that day.
Testing the rule means mocking the world. "Can I cancel a shipped order?" should be a one-line test. With the anemic shape, the test instantiates OrderService with six mocks, builds a fake Order with setStatus('shipped'), calls cancelOrder, asserts the exception. Five layers of ceremony to verify a single conditional. The test is doing the work of an entity test in the costume of a service test.
The service grows forever. Every new business operation is a new method on OrderService. Every method drags in another dependency. The constructor reaches ten parameters, then fifteen, and every test of every method has to provide all of them. The class can never be smaller than its largest method's needs. The team starts calling it "the God class" in standup, then stops mentioning it because the joke got stale.
The framework loves it. php artisan make:model Order produces this shape. So does Symfony's MakerBundle. So do most ORMs' "generate from schema" commands. The path of least resistance in the PHP ecosystem produces anemic domains. That is why this antipattern is everywhere, even on teams that read books about DDD on the weekend.
What to do instead
Move the behaviour onto the entity. Make the setters private. Let the constructor refuse invalid input. The service shrinks to a coordinator that loads, operates, and saves. The entity defends itself against every caller, present and future.
Here is the same Order, behaviour-rich:
<?php
declare(strict_types=1);
namespace App\Domain\Order;
use App\Domain\Customer\CustomerId;
use App\Domain\Shared\Money;
final class Order
{
private function __construct(
public readonly OrderId $id,
public readonly CustomerId $customerId,
private OrderStatus $status,
private Money $total,
public readonly \DateTimeImmutable $placedAt,
private ?\DateTimeImmutable $cancelledAt = null,
private ?string $cancellationReason = null,
private ?\DateTimeImmutable $fulfilledAt = null,
) {}
public static function place(
OrderId $id,
CustomerId $customerId,
Money $total,
\DateTimeImmutable $now,
): self {
if ($total->amountInMinorUnits <= 0) {
throw new \DomainException(
'Order total must be positive.'
);
}
return new self(
$id,
$customerId,
OrderStatus::Placed,
$total,
$now,
);
}
public function cancel(
\DateTimeImmutable $now,
?string $reason,
): void {
if ($this->status === OrderStatus::Cancelled) {
return;
}
if (!$this->status->canCancel()) {
throw new OrderAlreadyFulfilled($this->id);
}
$this->status = OrderStatus::Cancelled;
$this->cancelledAt = $now;
$this->cancellationReason = $reason;
}
public function fulfill(\DateTimeImmutable $now): void
{
if ($this->status !== OrderStatus::Paid) {
throw new \DomainException(
'Order must be paid before fulfilment.'
);
}
$this->status = OrderStatus::Fulfilled;
$this->fulfilledAt = $now;
}
public function status(): OrderStatus
{
return $this->status;
}
public function total(): Money
{
return $this->total;
}
}
The status is a native PHP 8.1 enum, not a string:
<?php
declare(strict_types=1);
namespace App\Domain\Order;
enum OrderStatus: string
{
case Placed = 'placed';
case Paid = 'paid';
case Fulfilled = 'fulfilled';
case Cancelled = 'cancelled';
case Refunded = 'refunded';
public function canCancel(): bool
{
return match ($this) {
self::Placed, self::Paid => true,
self::Fulfilled,
self::Cancelled,
self::Refunded => false,
};
}
}
And the service becomes three lines of orchestration:
<?php
declare(strict_types=1);
namespace App\Application\Order;
use App\Application\Port\Clock;
use App\Domain\Order\{OrderId, OrderNotFound, OrderRepository};
final readonly class CancelOrder
{
public function __construct(
private OrderRepository $orders,
private Clock $clock,
) {}
public function execute(CancelOrderInput $input): void
{
$order = $this->orders->find(new OrderId($input->orderId))
?? throw new OrderNotFound($input->orderId);
$order->cancel($this->clock->now(), $input->reason);
$this->orders->save($order);
}
}
Two dependencies. Eight lines of method body. No status check, no field assignments, no setCancelledAt. The use case loads the entity. It asks the entity to cancel. It hands the result to the repository. The rule about shipped orders is gone from this file. It lives on Order::cancel, where it can be enforced once for every caller.
What changed, mechanically
Read the two Order classes side by side and the differences sort themselves into four piles.
The constructor is private. The only way to create an Order is Order::place(...). There is no public new Order() that produces an invalid instance, because every named factory enforces what a valid Order actually requires. A negative total is rejected at construction. A missing customer ID is impossible. The class refuses to exist in a broken state.
The setters are gone. Every state transition has a named method: cancel, fulfill, markPaid, refund. The methods do the writes the setters used to do, but they do them as one operation with a domain name. You cannot search the codebase for setStatus and find it called from a controller; you search for cancel and find every cancellation in the system.
The state is an enum. string $status accepts any string, including 'banana'. OrderStatus accepts five values, all defined in one file, all checked at parse time. A typo crashes the file before it ever runs. The match expression in canCancel is exhaustive — add a sixth case and PHPStan tells you which other methods need updating.
Value objects replaced primitives. int $totalCents became Money $total. The Money value object refuses negative amounts in its own constructor and refuses to add euros to dollars in its own add method. The Order no longer has to remember any of those rules. They live where the data lives.
The use case lost six dependencies. None of them belong in CancelOrder: the mailer, the logger, the event dispatcher, the refund gateway. The mailer belongs in a listener for OrderCancelled, the logger belongs in middleware, the refund gateway belongs in its own use case. Splitting OrderService by verb shrank the file. It also diagnosed which dependencies were never about cancellation.
The fix that doesn't fix anything
The most common partial refactor: you added cancel() and fulfill() methods to the entity, but you left the setters in place because deleting them broke a thousand tests. Now $order->setStatus('cancelled') lives in the same file as $order->cancel($now, $reason). The invariant lives in two places. They drift. Six months later, somebody on the team writes a new code path that calls the setter, the invariant doesn't fire, and you have the same bug you had before, with the additional cost that the entity now looks like it should be safe.
Finish the job. Delete the setters. Make the constructor private. Force every state transition through a named entity method. If the tests break, the tests were testing setters instead of behaviour, and they were never load-bearing.
Doctrine users sometimes object that private constructors break hydration. They don't. Doctrine 3 hydrates through reflection without calling the constructor. Eloquent is more invasive, which is why the broader refactor splits the framework's Order extends Model from a pure-PHP domain Order and keeps Eloquent strictly inside the persistence adapter. The domain entity stays strict about how it can be built. The row, if there is one, is left as the framework's plaything.
The smell test
Three things to look for in your codebase tomorrow morning.
If your entities have more getters than methods that aren't getters, the rules live in services. If your largest service has more than seven public methods, those methods are entity behaviour wearing a service costume. If your tests for "can I cancel a shipped order" need a mock repository to run, the cancellation rule is in the wrong file.
The fix is mechanical. For every if in a service that protects a business rule (not authorization, not input validation, but the actual business rule), write a method on the entity that performs the operation the if was guarding. Move the check inside the method. Throw a typed domain exception. Then go back to the service and delete the if. The service shrinks. The entity grows. The rule lives once.
This isn't an architecture preference. The anemic shape is what you get when you let the framework's generator decide where rules go. That generator's output was never tested by someone debugging a bulk-cancel job that bypassed three of them. Name your verbs. Put them on the noun that owns them. Watch how much code goes away.
If this was useful
This pattern is one of four that Decoupled PHP walks through in detail — service dumping-ground, ActiveRecord-as-domain, repository-as-DAO, and the anemic-with-fat-services shape this post takes on. The book is the long version of this argument, with real Laravel and Symfony refactors, the project layout that keeps the domain framework-free, and a full reference application built from scratch in PHP 8.3.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)