>Dudenkoff_
← Lab / Laravel · Eloquent

One name, two things

Laravel 13 · PHP 8.5 · Postgres · July 18, 2026 · part 1 of 8

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.

app/Models/Order.php
<?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:

tinker
> 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?

tinker
> 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:

tinker
> $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:

tinker
> $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:

tinker
> 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:

tinker
> DB::table('line_items')->where('order_id', 1)->count();
= 2
app/Naive/EagerOrder.php
<?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:

tinker
> 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:

tinker
> 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:

tinker
> 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:

tinker
> 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):

vendor · Illuminate/Database/Eloquent/Concerns/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):

vendor · Illuminate/Database/Eloquent/Concerns/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:

tinker
> 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:

tinker
> 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:

tinker
> 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:

tinker
> 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.


Next
The list before the question
The series
  1. 01 One name, two things
  2. 02 The list before the question
  3. 03 Who picks up the phone
  4. 04 The ten that vanished
  5. 05 The copy you never asked for
  6. 06 The date that is not a cast
  7. 07 The id you never asked for
  8. 08 The row that would not stay dead