- Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- 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 PHP service you've been on the longest. Run this:
grep -rn "->getStatus()" src/ | wc -l
grep -rn "->getItems()" src/ | wc -l
grep -rn "->getCreatedAt()" src/ | wc -l
If those numbers are big, you don't have an object model. You have a bag of data that other code reaches into to make decisions. Every one of those call sites is a method that should have lived on the entity but didn't.
That is the whole "Tell, Don't Ask" principle in one shell command. You don't ask the object what's inside it and then decide. You tell the object what you want, and it decides. The decisions live with the data.
PHP frameworks make this easy to get wrong. Eloquent gives every column a magic getter. Doctrine generates one for every field. A controller hits a model, reads three properties, runs an if, and the rule that should have been a one-liner on the entity is now a paragraph in a service class. Two months later the same paragraph is in three places, and they don't all agree.
The grep test catches this in a few seconds. The fix is mechanical. Here are four real refactors.
The grep test, formalised
Before the refactors, the rule. A method belongs on the entity when the caller reads one or more fields of the entity and then makes a decision based on those reads. That is the whole signal. You are not deciding based on the calling code's context; you are deciding based on the entity's state. The decision is the entity's job.
Three patterns surface in grep:
-
$x->getA() === SomeEnum::B— boolean question about the entity's state. -
$x->getA() + $x->getB()— derived value computed from the entity's fields. -
if ($x->getA()) { $x->setA(...); }— guarded mutation based on current state.
All three are the entity talking through other people's mouths. Move the method.
The four examples below are framework-flavoured PHP 8.3. The same shape appears in every long-lived PHP codebase.
Example 1 — The cancel button that knew too much
Symfony controller. The order has a status enum and a paid_at timestamp. The product rule: a customer can cancel an order if it's still pending and was placed less than 30 minutes ago.
Before:
final class CancelOrderController
{
public function __invoke(
string $orderId,
OrderRepository $repo,
): Response {
$order = $repo->findById($orderId);
if ($order->getStatus() !== OrderStatus::Pending) {
throw new ConflictHttpException('not pending');
}
$age = (new \DateTimeImmutable())
->getTimestamp() - $order->getCreatedAt()->getTimestamp();
if ($age > 1800) {
throw new ConflictHttpException('too late');
}
$order->setStatus(OrderStatus::Cancelled);
$order->setCancelledAt(new \DateTimeImmutable());
$repo->save($order);
return new Response(null, 204);
}
}
The controller reads getStatus, getCreatedAt, and then writes setStatus and setCancelledAt. Four field-level interactions to express one rule. Grep ->getStatus() across the codebase and you'll find the same shape in the admin panel, the customer-service tool, and the queued retry worker. Three places, three different rules: one swallows the time check, one uses minutes instead of seconds, one forgets to set cancelledAt.
After:
final class Order
{
public function __construct(
public readonly string $id,
private OrderStatus $status,
public readonly \DateTimeImmutable $createdAt,
private ?\DateTimeImmutable $cancelledAt = null,
) {}
public function cancel(\DateTimeImmutable $now): void
{
if ($this->status !== OrderStatus::Pending) {
throw new OrderNotCancellable('not pending');
}
if ($now->getTimestamp() - $this->createdAt->getTimestamp() > 1800) {
throw new OrderNotCancellable('too late');
}
$this->status = OrderStatus::Cancelled;
$this->cancelledAt = $now;
}
}
Controller shrinks to three lines:
$order = $repo->findById($orderId);
$order->cancel(new \DateTimeImmutable());
$repo->save($order);
The rule lives in one place. The grep for ->getStatus() drops by three. The admin panel and the worker can call $order->cancel($now) and inherit the same guards, or call their own variant (cancelAsAdmin) that bypasses the time window. Explicit, named, auditable. The exception type is a domain exception; the controller maps it to HTTP. The clock comes in as an argument so the test doesn't sleep.
What changed: a boolean check (getStatus() !== Pending) and a derived value (age in seconds) both moved from outside to inside. The grep test caught both.
Example 2 — The total that lived in three services
A Laravel cart. Each Cart has LineItems, and the total is the sum of price * quantity plus tax minus discount. The total is computed in CheckoutController, in CartSummaryComponent, and in the EmailReceipt job.
Before:
class CheckoutController extends Controller
{
public function show(Cart $cart): View
{
$subtotal = 0;
foreach ($cart->items as $item) {
$subtotal += $item->price_cents * $item->quantity;
}
$tax = (int) round($subtotal * $cart->tax_rate);
$total = $subtotal + $tax - $cart->discount_cents;
return view('checkout', [
'cart' => $cart,
'total' => $total,
]);
}
}
Three call sites, three loops over $cart->items, three different rounding strategies. One of them rounded each line item; one rounded only the tax; the receipt subtracted the discount before tax. Customers got three different numbers for the same cart. Support handled the difference manually.
After:
final class Cart
{
public function __construct(
public readonly string $id,
/** @var list<LineItem> */
public readonly array $items,
public readonly float $taxRate,
public readonly int $discountCents,
) {}
public function subtotalCents(): int
{
return array_sum(array_map(
fn (LineItem $i) => $i->priceCents * $i->quantity,
$this->items,
));
}
public function taxCents(): int
{
return (int) round($this->subtotalCents() * $this->taxRate);
}
public function totalCents(): int
{
return $this->subtotalCents() + $this->taxCents() - $this->discountCents;
}
}
The controller now reads $cart->totalCents(). So does the component. So does the job. There is one rounding strategy and one definition of "total." If finance asks for the rule to change, you change one method.
Grep array_sum and foreach.*->items across the codebase. Each hit is a place that was reinventing the same arithmetic the entity could do for itself. Move them, one PR at a time.
The pattern: when a foreach over an entity's collection produces a derived number, the derivation belongs on the entity. The caller wanted the number, not the loop.
Example 3 — The if-then-set that lost a race
A subscription with three states: Active, Paused, Cancelled. A worker resumes paused subscriptions when payment succeeds.
Before:
public function handlePaymentSucceeded(string $subId): void
{
$sub = $this->repo->findById($subId);
if ($sub->getStatus() === SubscriptionStatus::Paused) {
$sub->setStatus(SubscriptionStatus::Active);
$sub->setResumedAt(new \DateTimeImmutable());
$this->repo->save($sub);
}
}
It looks fine. Read the state, decide, mutate. Two months in, two workers process the same webhook retry and both run the if. Both see Paused. Both write Active with two different resumedAt timestamps. The audit log has a duplicate transition. The billing system charges the prorated resume fee twice.
The bug is structural. Anyone who calls setStatus can put the subscription into any state regardless of the current one. The transition rule lives outside the entity, and outside is full of races.
After:
final class Subscription
{
private SubscriptionStatus $status;
private ?\DateTimeImmutable $resumedAt = null;
public function resume(\DateTimeImmutable $now): bool
{
if ($this->status !== SubscriptionStatus::Paused) {
return false;
}
$this->status = SubscriptionStatus::Active;
$this->resumedAt = $now;
return true;
}
}
setStatus is gone. The only way to get to Active from Paused is resume. The method returns a boolean so the worker knows whether it was the one that did the transition:
$sub = $this->repo->findById($subId);
if ($sub->resume(new \DateTimeImmutable())) {
$this->repo->save($sub);
$this->billing->chargeResumeFee($sub->id);
}
A second concurrent worker hits resume, sees the status is no longer Paused, returns false, and skips the billing call. The race is closed at the entity boundary, where the rule lives. Combined with an optimistic-lock column on the row, the database catches the rest.
Read the diff again. The thing that moved is the conditional if ($status === Paused). The grep test would have flagged it. The cost of not moving it was a double-charge incident.
Example 4 — The validation that lied to itself
A user can submit a refund request if they're verified, the order is older than 24 hours, and they haven't already submitted one for that order.
Before:
public function submitRefund(Request $request): JsonResponse
{
$user = $request->user();
$order = Order::find($request->input('order_id'));
if (!$user->isVerified()) {
return response()->json(['error' => 'not verified'], 403);
}
$age = now()->diffInHours($order->created_at);
if ($age < 24) {
return response()->json(['error' => 'too soon'], 409);
}
if (Refund::where('order_id', $order->id)->exists()) {
return response()->json(['error' => 'duplicate'], 409);
}
Refund::create([
'order_id' => $order->id,
'user_id' => $user->id,
]);
return response()->json(['status' => 'created']);
}
Two reads on two entities and a database query, all to decide whether one create-call is allowed. Now the mobile app needs the same rule. So does the support agent's tool. So does the auto-refund worker. Four copies, each one a little different.
After:
final class RefundRequest
{
public static function open(
User $user,
Order $order,
?\DateTimeImmutable $existingRefundAt,
\DateTimeImmutable $now,
): self {
if (!$user->isVerified()) {
throw new RefundNotAllowed('user not verified');
}
if ($now->diff($order->createdAt)->h < 24) {
throw new RefundNotAllowed('order too recent');
}
if ($existingRefundAt !== null) {
throw new RefundNotAllowed('duplicate');
}
return new self(
id: Uuid::v7()->toString(),
orderId: $order->id,
userId: $user->id,
createdAt: $now,
);
}
}
The factory holds the rule. Every caller funnels through RefundRequest::open(...): the controller, the mobile API, the support tool, the worker. They all get the same answer. The existing-refund check is passed in as data rather than queried inside the constructor, which keeps the entity free of database access. The application service is responsible for the query; the entity is responsible for the rule.
This is the version of "Tell, Don't Ask" that respects hexagonal boundaries. The entity decides. The repository fetches. The use case wires them. None of them reach across into the others.
What the rule isn't
A few things "Tell, Don't Ask" does not require.
It does not mean "no getters." Reading a field for display, serialisation, or logging is fine — the caller isn't making a decision. The rule fires when the read feeds an if or an arithmetic expression that produces something the entity could produce itself.
It does not mean "fat models." The methods you move are usually small: one boolean, one sum, one guarded transition. The entity gets a handful of named verbs. The service classes shrink correspondingly. The total line count goes down.
It does not require DDD ceremony. No aggregate roots, no value objects, no domain events. You can apply the grep test to a Laravel app full of Eloquent models tomorrow. You just stop adding Service::computeTotal($cart) when $cart->total() is right there.
And it does not require an ORM rewrite. Doctrine entities, Eloquent models, plain classes hydrated from a query — all of them accept methods. The framework doesn't care.
How to roll this out without freezing the codebase
Three steps, in order, on any team that's already shipping:
- Grep first. Count call sites for the top-five entity getters in your codebase. Sort by hit count. The top three are your refactor backlog.
-
One getter at a time. Pick
getStatus. Find every site that reads it and follows up with anif. Move the boolean to a named method on the entity (isPending,canCancel,awaitingPayment). Replace the call sites in one PR per concept. -
Lock the gate. Once a getter has no decision-making callers left, mark it
@internal(PHPStan rule) or remove it entirely if your hydrator allows. New callers can't bring back the old shape because the door is closed.
Every step ships independently. No freeze week. No "big refactor PR" that blocks four teams.
After a few rounds, the grep numbers fall. The rule is no longer a poster on the wall; it's enforceable from CI. And the next person reading the code finds the cancel rule next to the cancel timestamp, the total next to the items, the resume guard next to the status. Where the data is, the decision is.
If this was useful
The grep test is one small piece of a longer argument: that PHP applications get harder to change because the rules drift away from the data, and that hexagonal and clean architecture are mostly about putting them back. Decoupled PHP walks the full path: domain at the centre, ports as the contract, frameworks demoted to adapters. Same PHP 8.3 code style you've been reading here.
Available on Kindle, Paperback, and Hardcover. English, German, and Japanese editions out now — Portuguese and Spanish coming soon.



Top comments (0)