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. The problem begins when an API controller, an import command, and an admin action each reproduce that sequence with slightly different conditions.
“Tell, don’t ask” is useful when it moves a decision back to the object that owns the relevant state. It is not a ban on queries, getters, or conditional logic. The smell is asking an object for its internals so another object can enforce its rules.
Andy Hunt’s original explanation frames the principle as putting the decision with the data it needs. Martin Fowler’s later qualification is equally important: responsible queries can be clearer, and layering can outweigh co-locating data and behavior.
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();
});This is more than replacing an if with a method. Every caller 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. A domain transition has been mixed with application coordination.
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 makes another constraint visible: 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 observing a transaction that later rolls back; it 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.
The entity should not solve that infrastructure problem merely to satisfy an object-oriented slogan.
Asking is correct when the caller owns the question
Reads are not failed commands. A resource that renders 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.
The practical test is ownership: if the caller is deciding how to display, aggregate, or route information, asking is appropriate. If it is deciding whether an object’s state may change and how that change preserves an invariant, the object probably owns the decision.
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. Nothing prevents a
caller from skipping canBePlaced(), forgetting the timestamp, or changing
the status after a stale check.
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 while missing the rule it was meant to protect.
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. The goal is not fewer question marks. It is one authoritative place for each rule.