$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.
This is the useful part of the Law of Demeter: keep structural knowledge near the object or module that owns the structure. Counting arrows is a poor substitute for that reasoning. A fluent query builder can have a long chain without crossing a boundary; a single getter can expose an object that the caller has no business navigating.
The question is not “how many dots are allowed?” It is “who should know how to reach this value or perform this action?”
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 complicate the literal rule with collections, builders, proxies, and ORM relationships, but the design pressure remains useful: a caller should not acquire detailed knowledge 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.
It is not free. Calling shippingCity() can still lazy-load two relationships.
Encapsulation changes who knows the path; it does not change the SQL. Laravel’s
relationship documentation distinguishes lazy and
eager loading and explains how eager loading addresses N+1 queries. Coupling
and query count are separate problems, even when the same line of code 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 becomes useful 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.
The alternative—a direct Eloquent query in the controller—is credible for a single local endpoint. Prefer it while the query is short, used once, and unlikely to acquire independent policy. Extracting an interface on the first property access merely 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.” This is stronger than wrapping the original chain in a getter because the decision no longer escapes.
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 add 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.