This controller looks reasonable:
if ($order->status === OrderStatus::Draft && $order->lines->isNotEmpty()) {
$order->status = OrderStatus::Placed;
$order->placed_at = now();
$order->save();
}It reads state, makes a decision, mutates more state, and persists the result. That is fine once. The mess starts when an API controller, an import command, and an admin action each reproduce the sequence with slightly different conditions.
“Tell, don’t ask” helps when it moves a decision back to the object that owns the relevant state. Turning it into a ban on getters, queries, or conditional logic misses the point. The problem is asking an object for its internals so somebody else can enforce its rules.
Andy Hunt’s original explanation puts the decision with the data it needs. Martin Fowler’s later qualification stops that advice from becoming dogma: responsible queries can be clearer, and layering can matter more than forcing data and behavior into the same object.
The branch is the behavior
The original code contains at least three rules:
- only a draft order can be placed;
- an empty order cannot be placed; and
- placing records both status and time.
Those rules form one state transition. Expose that transition directly:
final class Order extends Model
{
public function place(CarbonImmutable $placedAt): void
{
if ($this->status !== OrderStatus::Draft) {
throw OrderCannotBePlaced::becauseItIsNotDraft($this->id);
}
if ($this->lines->isEmpty()) {
throw OrderCannotBePlaced::becauseItHasNoLines($this->id);
}
$this->status = OrderStatus::Placed;
$this->placed_at = $placedAt;
}
}The application service now tells the order what should happen:
DB::transaction(function () use ($order): void {
$order->place($this->clock->now());
$order->save();
});Moving the if into a method is not the achievement. Every caller now crosses
the same transition boundary, invalid transitions have names, and tests can
exercise the rule without an HTTP request.
Passing the time is deliberate. A model that calls now() hides a changing
input and makes exact tests harder. Telling an object what to do does not mean
letting it acquire every dependency it might need.
Do not move application orchestration into the entity
The slogan becomes harmful when “put behavior with data” turns an Eloquent model into a service locator:
public function place(): void
{
$this->status = OrderStatus::Placed;
$this->save();
Mail::to($this->customer)->send(new OrderPlacedMail($this));
ProcessPayment::dispatch($this->id);
}Now the entity owns persistence, email, queue dispatch, and transaction timing. It may look impressively behavior-rich, but a domain transition has been mixed with half the application layer.
Keep the entity responsible for the decision and state change. Let the use case coordinate infrastructure:
final readonly class PlaceOrder
{
public function __construct(
private Orders $orders,
private Clock $clock,
private EventBus $events,
) {}
public function handle(OrderId $id): void
{
$event = DB::transaction(function () use ($id): OrderPlaced {
$order = $this->orders->getForUpdate($id);
$order->place($this->clock->now());
$this->orders->save($order);
return new OrderPlaced($order->id, $order->placed_at);
});
$this->events->dispatch($event);
}
}This boundary exposes another constraint: dispatching before commit can expose state that later rolls back. Laravel supports events and queued listeners that run after commit . That prevents a listener from seeing a transaction that later rolls back. It still does not make the database commit and message delivery one atomic operation. AWS’s transactional-outbox guidance documents that dual-write gap. An outbox may be necessary when losing the event after a successful commit is unacceptable.
Do not dump that infrastructure problem into the entity merely to satisfy an object-oriented slogan.
Asking is correct when the caller owns the question
Reads are not failed commands. A resource rendering an order should ask for values:
return [
'id' => $order->id,
'status' => $order->status->value,
'placed_at' => $order->placed_at?->toAtomString(),
];The resource owns representation. Moving JSON formatting into Order would
couple the domain model to one delivery format without protecting a business
rule.
Queries also ask by definition:
$lateShipments = Shipment::query()
->where('status', ShipmentStatus::InTransit)
->where('estimated_at', '<', now())
->count();The reporting use case needs information; there is no entity transition to encapsulate. “Tell the shipment to report itself” would only obscure the read.
Ownership gives us a better test than counting getters. If the caller decides how to display, aggregate, or route information, asking is appropriate. If the caller decides whether another object’s state may change and how that change preserves an invariant, the decision has probably leaked.
A method that returns a boolean may still leak the rule
This refactor looks encapsulated:
if ($order->canBePlaced()) {
$order->status = OrderStatus::Placed;
$order->save();
}The eligibility rule moved, but mutation remains outside. A caller can still
skip canBePlaced(), forget the timestamp, or change the status after the
answer has gone stale.
Prefer one operation that checks and changes the in-memory state without exposing an intermediate authorization result:
$order->place($clock->now());Boolean queries remain useful for user interfaces, where a disabled button or available-action list is advisory. The command must still enforce the rule; the UI check can become stale between rendering and submission.
Tests should name transitions, not property assignments
An entity test can state the contract directly:
it('cannot place an empty order', function () {
$order = Order::draft();
expect(fn () => $order->place(at('2026-07-11T10:00:00Z')))
->toThrow(OrderCannotBePlaced::class);
});
it('records the placement time', function () {
$order = Order::draft()->withLine(aProductLine());
$placedAt = at('2026-07-11T10:00:00Z');
$order->place($placedAt);
expect($order->status)->toBe(OrderStatus::Placed)
->and($order->placed_at)->toEqual($placedAt);
});The application test has a different job: prove locking, persistence, and event timing. Mocking every getter and setter would preserve the old design’s shape perfectly while missing the rule we actually care about.
Use “tell, don’t ask” to find decisions separated from their state. Move the complete transition, keep orchestration at the application boundary, and leave honest reads alone. Do not count question marks or getters. Find the one place that must remain authoritative for the rule.