One name, two things
Two lines keep turning up in Laravel code. $order->customer
, and $order->customer()
. Same word, one with parentheses. In plain PHP that is a property and a method, two different members of a class. In Eloquent they come from a single line on the model and behave nothing alike. I want to know why. I run it on a small bench, one order and its customer to start, and keep the query log on the whole way, because the count is the story here.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['customer_id', 'total'])]
class Order extends Model
{
public function customer(): BelongsTo
{
return $this->belongsTo(Customer::class);
}
}
One method, customer()
. There is no property named customer
on the class at all. Log on, load order 1, and it costs a single query:
> DB::enableQueryLog();
= null
> $o = App\Models\Order::find(1);
= App\Models\Order {#8141
id: 1,
customer_id: 1,
total: 100,
created_at: "2026-07-18 14:06:04",
updated_at: "2026-07-18 14:06:04",
}
> array_column(DB::getQueryLog(), 'query');
= [
"select * from \"orders\" where \"orders\".\"id\" = ? limit 1",
]
One order, one query. Now the form with the parentheses. What is it?
> class_basename($o->customer());
= "BelongsTo"
> $o->customer()->toSql();
= "select * from \"customers\" where \"customers\".\"id\" = ?"
> count(DB::getQueryLog());
= 1
A BelongsTo
, not a customer. Ask it for its SQL and it shows the query it is holding, aimed at the customers table and not yet run. The count is still one: calling the method asked the database for nothing. Drop the parentheses and the count moves:
> $o->customer;
= App\Models\Customer {#8145
id: 1,
name: "Alice",
created_at: "2026-07-18 14:06:04",
updated_at: "2026-07-18 14:06:04",
}
> count(DB::getQueryLog());
= 2
Alice, and a second query. The property ran what the method only prepared. Read it again:
> $o->customer;
= App\Models\Customer {#8145
id: 1,
name: "Alice",
created_at: "2026-07-18 14:06:04",
updated_at: "2026-07-18 14:06:04",
}
> count(DB::getQueryLog());
= 2
Same object, #8145
both times, and the count holds at two. Loaded once, then cached. One more test on the parenthesized half: if it is a query that has not run, I can add to it. I flush the log and ask whether this order's customer is named Bob:
> DB::flushQueryLog();
= null
> $o->customer()->where('name', 'Bob')->exists();
= false
> array_column(DB::getQueryLog(), 'query');
= [
"select exists(select * from \"customers\" where \"customers\".\"id\" = ? and \"name\" = ?) as \"exists\"",
]
False, and my where
slid in next to the relation's own condition, id = ? and name = ?
. So a method that returns an open query, a property that runs it once and keeps the answer. Why not the obvious way, a plain property filled on load?
The naive version, and why it dies#
Build the obvious thing and run it: a relation as a plain property, filled when the order loads. To make it hurt I need a little depth. Order 1 has two line items, each pointing at a product:
> DB::table('line_items')->where('order_id', 1)->count();
= 2
<?php
namespace App\Naive;
use Illuminate\Support\Facades\DB;
class EagerOrder
{
public int $id;
public $customer; // a plain property, filled at load
public $items; // and this, each item with its product
public static function find(int $id): self
{
$row = DB::table('orders')->where('id', $id)->first();
$o = new self();
$o->id = $row->id;
$o->customer = DB::table('customers')->where('id', $row->customer_id)->first();
$o->items = DB::table('line_items')->where('order_id', $id)->get();
foreach ($o->items as $item) {
$item->product = DB::table('products')->where('id', $item->product_id)->first();
}
return $o;
}
}
Clear the log and load one order the naive way. It comes back with the customer, both items, and both products already filled:
> DB::flushQueryLog();
= null
> $naive = App\Naive\EagerOrder::find(1);
= App\Naive\EagerOrder {#8592
+id: 1,
+customer: {#8591
+"id": 1,
+"name": "Alice",
+"created_at": "2026-07-18 14:06:04",
+"updated_at": "2026-07-18 14:06:04",
},
+items: Illuminate\Support\Collection {#7457
all: [
{#8596
+"id": 1,
+"order_id": 1,
+"product_id": 1,
+"qty": 2,
+"created_at": "2026-07-18 14:06:04",
+"updated_at": "2026-07-18 14:06:04",
+"product": {#7452
+"id": 1,
+"name": "Keyboard",
+"created_at": "2026-07-18 14:06:04",
+"updated_at": "2026-07-18 14:06:04",
},
},
{#8594
+"id": 2,
+"order_id": 1,
+"product_id": 2,
+"qty": 1,
+"created_at": "2026-07-18 14:06:04",
+"updated_at": "2026-07-18 14:06:04",
+"product": {#8586
+"id": 2,
+"name": "Mouse",
+"created_at": "2026-07-18 14:06:04",
+"updated_at": "2026-07-18 14:06:04",
},
},
],
},
}
Here is what that cost, five queries for one order:
> array_column(DB::getQueryLog(), 'query');
= [
"select * from \"orders\" where \"id\" = ? limit 1",
"select * from \"customers\" where \"id\" = ? limit 1",
"select * from \"line_items\" where \"order_id\" = ?",
"select * from \"products\" where \"id\" = ? limit 1",
"select * from \"products\" where \"id\" = ? limit 1",
]
Five, and this graph is shallow. Clear the log again and load the real model, which stays at one:
> DB::flushQueryLog();
= null
> $real = App\Models\Order::find(1);
= App\Models\Order {#8581
id: 1,
customer_id: 1,
total: 100,
created_at: "2026-07-18 14:06:04",
updated_at: "2026-07-18 14:06:04",
}
> count(DB::getQueryLog());
= 1
One, plus only what I later reach for. That is not a case against eager loading; Laravel ships it. Flush the log, run Order::with('customer')->find(1)
, and the customer loads on purpose, in two queries:
> DB::flushQueryLog();
= null
> App\Models\Order::with('customer')->find(1);
= App\Models\Order {#8608
id: 1,
customer_id: 1,
total: 100,
created_at: "2026-07-18 14:06:04",
updated_at: "2026-07-18 14:06:04",
customer: App\Models\Customer {#8610
id: 1,
name: "Alice",
created_at: "2026-07-18 14:06:04",
updated_at: "2026-07-18 14:06:04",
},
}
> array_column(DB::getQueryLog(), 'query');
= [
"select * from \"orders\" where \"orders\".\"id\" = ? limit 1",
"select * from \"customers\" where \"customers\".\"id\" in (1)",
]
So eagerness is a lever, not a default. EagerOrder
was wrong only in pulling it always, down the whole graph, with no brakes. A relation cannot be a plain value on the object, because filling it on load would fill everything. It has to be a query you run on demand. So the method returns the query, and the property runs it once.
Open the box#
That is the behavior. Now the mechanism. Reading a property that is not a column lands in one branch of getAttribute
(HasAttributes.php):
return $this->isRelation($key) || $this->relationLoaded($key)
? $this->getRelationValue($key)
: $this->throwMissingAttributeExceptionIfApplicable($key);
No column named customer
, so it reaches isRelation
, which for our plain method comes down to method_exists($this, $key)
. The class has a customer()
method, so the property is treated as a request to run it. And the caching sits in getRelationValue
(HasAttributes.php):
// ... there is no need to query within the relations twice.
if ($this->relationLoaded($key)) {
return $this->relations[$key];
}
// ...
return $this->getRelationshipFromMethod($key);
The first read runs the query and files the result under relations['customer']
. Every read after returns the file. That is the same #8145
coming back twice.
That also closes the question in the title. There were never two members with one name. The class has exactly one, the method; the property exists nowhere, so reading it falls through to the method, runs it once, and files the result. One declared relationship, and Eloquent grows both faces from it: the open query when you want to steer it, the kept answer when you just want the customer.
The loop that bites#
The lazy property fires a query the moment you touch it, quietly. One order is fine. So I give Alice two more orders:
> App\Models\Order::create(['customer_id' => 1, 'total' => 50]);
= App\Models\Order {#8588
customer_id: 1,
total: 50,
updated_at: "2026-07-18 14:06:09",
created_at: "2026-07-18 14:06:09",
id: 2,
}
> App\Models\Order::create(['customer_id' => 1, 'total' => 75]);
= App\Models\Order {#7415
customer_id: 1,
total: 75,
updated_at: "2026-07-18 14:06:09",
created_at: "2026-07-18 14:06:09",
id: 3,
}
Three orders now, all Alice's. Flush the log, load them all, one query for the list:
> DB::flushQueryLog();
= null
> $orders = App\Models\Order::all();
= Illuminate\Database\Eloquent\Collection {#8609
all: [
App\Models\Order {#8137
id: 1,
customer_id: 1,
total: 100,
created_at: "2026-07-18 14:06:04",
updated_at: "2026-07-18 14:06:04",
},
App\Models\Order {#8588
id: 2,
customer_id: 1,
total: 50,
created_at: "2026-07-18 14:06:09",
updated_at: "2026-07-18 14:06:09",
},
App\Models\Order {#8589
id: 3,
customer_id: 1,
total: 75,
created_at: "2026-07-18 14:06:09",
updated_at: "2026-07-18 14:06:09",
},
],
}
> count(DB::getQueryLog());
= 1
Now loop and reach for each order's customer:
> foreach ($orders as $order) { $order->customer; }
> array_column(DB::getQueryLog(), 'query');
= [
"select * from \"orders\"",
"select * from \"customers\" where \"customers\".\"id\" = ? limit 1",
"select * from \"customers\" where \"customers\".\"id\" = ? limit 1",
"select * from \"customers\" where \"customers\".\"id\" = ? limit 1",
]
One to list, one for every order in the loop. The ids hide behind the ?
, so I pull the bindings:
> array_column(DB::getQueryLog(), 'bindings');
= [
[],
[
1,
],
[
1,
],
[
1,
],
]
Every lookup is id 1
. The same customer, Alice, fetched three times, because each order held its own empty cache and none reused the first. Stretch the loop to a hundred and it is a hundred and one queries, none of them your idea. This is the N+1, and with('customer')
is the one line that folds it back to two, asking for Alice just once. How it does that is the next thing to take apart.
- 01 One name, two things
- 02 The list before the question
- 03 Who picks up the phone
- 04 The ten that vanished
- 05 The copy you never asked for
- 06 The date that is not a cast
- 07 The id you never asked for
- 08 The row that would not stay dead