$order->customer->primaryAddress->city looks harmless. It may even execute
the right number of queries. The problem appears six months later, when an
order can have several delivery destinations and every caller has encoded the
old path to a city.
Forget counting arrows. The useful part of the Law of Demeter is keeping structural knowledge near the object or module that owns the structure. A fluent query builder can have a long chain without crossing a boundary; one innocent getter can expose an object the caller has no business navigating. The question is who should know how to reach the value or perform the action, not how many dots the expression contains.
The chain reveals a second contract
Consider a controller that returns an order’s shipping city:
final class OrderCityController
{
public function __invoke(Order $order): JsonResponse
{
return response()->json([
'city' => $order->customer->primaryAddress->city,
]);
}
}The route contract appears to be Order -> city. Its implementation also
makes the controller depend on Order -> Customer -> Address -> city. That
second contract is accidental and much larger. Renaming the relationship,
moving addresses behind a delivery profile, or deriving the locality from a
shipment now changes an HTTP adapter.
The original Law of Demeter paper described a class-oriented rule for limiting which classes a method may send messages to. Modern PHP and Laravel make the literal rule awkward with collections, builders, proxies, and ORM relationships. The design pressure still holds: a caller should not need a map of a collaborator’s collaborators.
This is why the following chain is not automatically suspicious:
$orders = Order::query()
->where('status', OrderStatus::Pending)
->latest()
->limit(20)
->get();Each method operates on and returns the query abstraction. The caller is not
walking through Builder internals. The chain is the public language of one
module.
Put the answer behind the boundary that owns it
The smallest correction may be a method on Order:
final class Order extends Model
{
public function shippingCity(): ?string
{
return $this->customer
->primaryAddress
?->city;
}
}return response()->json(['city' => $order->shippingCity()]);That method is appropriate when the answer is part of the order’s useful domain vocabulary and the model already owns the required relationships. The controller now depends on the question it needs answered, not the current route through the object graph.
That method fixes ownership, not query count. Calling shippingCity() can
still lazy-load two relationships. Laravel’s relationship
documentation
distinguishes lazy and eager loading
and explains how eager loading addresses N+1 queries. Coupling and SQL are
separate problems, even when the same line exposes both.
For a list endpoint, make loading explicit:
$orders = Order::query()
->with('customer.primaryAddress')
->latest()
->get();
return OrderResource::collection($orders);For twenty orders, lazy traversal can turn the initial order query into as
many as 41 queries: one for the orders, twenty for customers, and twenty for
addresses. The nested eager load makes that shape three queries instead. The
exact count depends on the relationships, but hiding the traversal inside
shippingCity() never makes it free.
A feature test should protect both the response and the loading decision if a
regression would be expensive. Enabling
Model::preventLazyLoading()
outside production can
also turn an accidental relationship load into a visible failure during
development and testing.
A query object earns its place when the read has its own shape
Putting every traversal behind an interface creates ceremony, not encapsulation. A query object starts paying for itself when the read no longer belongs naturally to one model—for example, when it joins shipment data, applies authorization, or returns a projection used by several delivery adapters.
interface FindOrderDelivery
{
public function forOrder(OrderId $orderId): ?OrderDelivery;
}
final readonly class OrderDelivery
{
public function __construct(
public string $city,
public DeliveryStatus $status,
public ?CarbonImmutable $estimatedAt,
) {}
}An Eloquent implementation can own the join and projection:
final class EloquentFindOrderDelivery implements FindOrderDelivery
{
public function forOrder(OrderId $orderId): ?OrderDelivery
{
$shipment = Shipment::query()
->with('destination')
->where('order_id', $orderId->value)
->latest('created_at')
->first();
if ($shipment === null) {
return null;
}
return new OrderDelivery(
city: $shipment->destination->city,
status: $shipment->status,
estimatedAt: $shipment->estimated_at,
);
}
}The controller sees a delivery read model. Only the query implementation knows that the current answer comes from the latest shipment and its destination. If the read later moves to a reporting table or remote service, that is a real reason for the boundary to have multiple implementations.
A direct Eloquent query in the controller is perfectly credible for one local endpoint. Keep it while the query is short, used once, and unlikely to acquire independent policy. Extracting an interface on the first property access only predicts a future that may never arrive.
Tell, don’t ask solves a different half of the problem
Sometimes the caller does not need data at all. It asks for state only so it can make a decision that belongs to the object:
if ($order->payment->status === PaymentStatus::Captured) {
$order->markReadyForFulfilment();
}Moving the decision into the order removes both traversal and leaked policy:
$order->markReadyForFulfilment($payment);final class Order
{
public function markReadyForFulfilment(Payment $payment): void
{
if (! $payment->isCaptured()) {
throw OrderCannotBeFulfilled::paymentIsIncomplete();
}
$this->status = OrderStatus::Ready;
}
}The order owns the transition; the payment owns the meaning of “captured.” Wrapping the original chain in a getter would hide the path while leaving the decision outside. Moving the operation keeps the decision from escaping in the first place.
It also shows where not to apply the technique. A serializer genuinely needs
to read values. Turning OrderResource into a sequence of imperative commands
would obscure its job. Use behavior-bearing methods for decisions and
purposeful projections for reads.
Refactor from the repeated knowledge
Start with the path that changes often or appears in several callers. Protect one representative behavior with a characterization test. Then move the traversal to the narrowest owner that can answer the real question:
- a method on the object when the answer belongs to that object’s vocabulary;
- a projection or query object when the read spans models or has independent loading and authorization rules; or
- a domain operation when the caller is extracting state only to make a decision.
Stop after the knowledge has one owner. Do not celebrate by adding a repository, interface, DTO, decorator, and container binding unless each solves a constraint visible in the code. If the new abstraction only forwards a getter and always changes with its caller, collapse it.
The Law of Demeter earns its keep when it points to leaked knowledge. Ignore the punctuation, find the accidental contract, and put the answer where a future structural change has one obvious place to land.