>Dudenkoff_
← Lab / Laravel · Eloquent

The list before the question

Laravel 13 · PHP 8.5 · Postgres · July 19, 2026 · part 2 of 8

The opener ended on a promise: a loop over three orders cost four queries, the same customer fetched three times. It had a name for that loop, the N+1, and one line, with('customer') , that was supposed to fold it back to two, asking for Alice once. Time to take the line apart. Same bench, query log on from the start, one question: how does it know what to ask for before anything asks? The count is still the story.

For anyone landing here first, the model under test is small:

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 declared relation, customer() . Alice still owns the three orders the opener left her. Here is the state both tables are in, every count below leaning on it:

tinker
> DB::table('orders')->get(['id', 'customer_id']);
= Illuminate\Support\Collection {#7478
    all: [
      {#7477
        +"id": 1,
        +"customer_id": 1,
      },
      {#7476
        +"id": 2,
        +"customer_id": 1,
      },
      {#7473
        +"id": 3,
        +"customer_id": 1,
      },
    ],
  }
> DB::table('customers')->get(['id', 'name']);
= Illuminate\Support\Collection {#7455
    all: [
      {#7458
        +"id": 1,
        +"name": "Alice",
      },
    ],
  }

Three orders pointing at customer 1, one customer to point at. Log on, and a loop touching each order's customer:

tinker
> DB::enableQueryLog();
= null
> DB::flushQueryLog();
= null
> foreach (App\Models\Order::all() 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",
  ]
> array_column(DB::getQueryLog(), 'bindings');
= [
    [],
    [
      1,
    ],
    [
      1,
    ],
    [
      1,
    ],
  ]

Four queries, every binding 1. One for the list, then Alice three times over. The same numbers the opener ended on.

The line under investigation goes into the same kind of loop:

tinker
> DB::flushQueryLog();
= null
> foreach (App\Models\Order::with('customer')->get() as $order) { $order->customer; }
> array_column(DB::getQueryLog(), 'query');
= [
    "select * from \"orders\"",
    "select * from \"customers\" where \"customers\".\"id\" in (1)",
  ]

Two. The orders query, then one customers query naming her directly, in (1) . Both promises land at once: four fold to two, Alice asked for once. And before the loop touched its first customer, one query had already fetched everything it would need. How did it know the list? A detail filed for later, too: that 1 is a literal in the SQL. The lazy lookups hid their id behind a ? .

The bet#

I have a bet. The opener located the pain: each order carries its own empty relation cache, so order 2 cannot reuse what order 1 fetched. The obvious fix is one shared dictionary, keyed by customer id. Here it is, the opener's naive loader cut down to one relation:

app/Naive/MemoOrder.php
<?php

namespace App\Naive;

use Illuminate\Support\Facades\DB;

class MemoOrder
{
    public int $id;
    public $customer;   // filled from a shared memo, not a per-instance cache

    public static array $customers = [];   // one dictionary, shared across every load

    public static function all(): array
    {
        $orders = [];

        foreach (DB::table('orders')->get() as $row) {
            $o = new self();
            $o->id = $row->id;

            if (! isset(self::$customers[$row->customer_id])) {
                self::$customers[$row->customer_id] = DB::table('customers')->where('id', $row->customer_id)->first();
            }
            $o->customer = self::$customers[$row->customer_id];

            $orders[] = $o;
        }

        return $orders;
    }
}

The dictionary starts empty; the listing says so, static $customers = []; . Each load checks it before touching the database. If the bet is right, the loop costs two queries, orders plus Alice's first appearance, matching with() :

tinker
> DB::flushQueryLog();
= null
> foreach (App\Naive\MemoOrder::all() as $order) { $order->customer; }
> count(DB::getQueryLog());
= 2

Two. And the dictionary holds what it should:

tinker
> App\Naive\MemoOrder::$customers;
= [
    1 => {#7413
      +"id": 1,
      +"name": "Alice",
      +"created_at": "2026-07-19 09:20:33",
      +"updated_at": "2026-07-19 09:20:33",
    },
  ]

Alice, keyed by 1. The bet looks won. On these three orders it is indistinguishable from the truth: my memo prints the same 2 the real mechanism prints. The code differs; the data cannot see it. The bench is too small to judge.

A hundred customers#

The opener claimed a hundred orders make a hundred and one queries. I want to see it, and I want the memo facing customers who are not Alice. Creating them touches the Customer model for the first time; it is even smaller:

app/Models/Customer.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;

#[Fillable(['name'])]
class Customer extends Model
{
    //
}

A hundred customers, one order each, born in a loop:

tinker
> foreach (range(2, 101) as $i) { $c = App\Models\Customer::create(['name' => "Customer $i"]); App\Models\Order::create(['customer_id' => $c->id, 'total' => 100]); }
> [App\Models\Customer::count(), App\Models\Order::count()];
= [
    101,
    103,
  ]

101 customers, 103 orders. The bare loop first, no memo, no with() . One query for the list plus one per order predicts 104, three over the opener's round hundred and one:

tinker
> DB::flushQueryLog();
= null
> foreach (App\Models\Order::all() as $order) { $order->customer; }
> count(DB::getQueryLog());
= 104

104. The promise holds, shifted by our extra three. This is the N+1 problem at full size: one query for the list, then one per order, a count that grows with the table.

The memo's turn. Its dictionary still holds Alice, so I empty it for a clean start. Before looking, count what a memo can save. It pays once per distinct customer, and the only repeated customer in these 103 orders is Alice:

tinker
> App\Naive\MemoOrder::$customers = [];
= []
> DB::flushQueryLog();
= null
> foreach (App\Naive\MemoOrder::all() as $order) { $order->customer; }
> count(DB::getQueryLog());
= 102
> count(App\Naive\MemoOrder::$customers);
= 101

102 queries, 101 dictionary entries. The memo paid for every distinct customer once; the two queries it saved are the second and third touches of Alice. The hundred and one first meetings it could not help. Scale killed the bet, and it kills anything shaped like it: a cache answering during the loop, one miss at a time, grows with the loop. Deduplication is not folding, and the N+1 is not a caching problem.

Same 103 orders, the real line. Does it hold?

tinker
> DB::flushQueryLog();
= null
> foreach (App\Models\Order::with('customer')->get() as $order) { $order->customer; }
> count(DB::getQueryLog());
= 2

Two. A hundred and one distinct customers and the price did not move. with() is one more query, not a hundred. So it is no memo. What is it?

Doing it by hand#

The corpse points at the answer. A cache answers one question at a time while the loop runs, and every first meeting costs a query. A flat price means one question with every key in it, the whole list gathered before the first question to customers goes out. The list sits in the orders already, in customer_id . So, eager loading by hand. One device first: chaining an assignment and a count() on one line prints a number instead of a hundred row dumps; that is its only purpose.

tinker
> $rows = DB::select('select * from orders'); count($rows);
= 103

All 103 orders in hand.

tinker
> $ids = array_values(array_unique(array_column($rows, 'customer_id'))); count($ids);
= 101

101 distinct ids; array_unique folded Alice's three orders into one key. A glance at the first five:

tinker
> array_slice($ids, 0, 5);
= [
    1,
    2,
    3,
    4,
    5,
  ]

Small integers, in the order they went in. Now the one question with the whole list in it. The ids are integers from my own database, so I glue them in as literals and think nothing of it; hold that instinct. The stitching after is array work, so my bet is the log ends at one:

tinker
> DB::flushQueryLog();
= null
> $customers = DB::select('select * from customers where id in (' . implode(', ', $ids) . ')'); count($customers);
= 101

101 customers, one round trip. Build the dictionary, then read a customer the way the loop would:

tinker
> $byId = []; foreach ($customers as $c) { $byId[$c->id] = $c; }
> $rows[0]->customer_id;
= 1
> $byId[$rows[0]->customer_id]->name;
= "Alice"
> count(DB::getQueryLog());
= 1

Order 1 points at customer 1, the dictionary answers Alice, and the log still says one. Three verbs: collect the keys, ask once with the list, match through a dictionary. Eager loading, hand-rolled.

Open the box again#

Time to compare with the vendor. If with() thinks like the hand-rolled version, three verbs should be waiting inside: a key collector, a single in , a dictionary. I will trace them with the three orders from the top.

Step one: collect eager model keys#

The collector lives in BelongsTo.php:

vendor · Illuminate/Database/Eloquent/Relations/BelongsTo.php
protected function getEagerModelKeys(array $models)
{
    $keys = [];

    // First we need to gather all of the keys from the parent models so we know what
    // to query for via the eager loading query. We will add them to an array then
    // execute a "where in" statement to gather up all of those related records.
    foreach ($models as $model) {
        if (! is_null($value = $this->getForeignKeyFrom($model))) {
            $keys[] = $value;
        }
    }

    sort($keys);

    return array_values(array_unique($keys));
}

Takes all the models, the orders here, and pulls the foreign key off each one, the id of the customer it points at. For our three orders that is [1, 1, 1] , and array_unique folds it down to [1] . (The is_null guard drops any order pointing at nobody; ours are NOT NULL , so all three survive.)

Now addEagerConstraints takes that list and assembles the question:

vendor · Illuminate/Database/Eloquent/Relations/BelongsTo.php
public function addEagerConstraints(array $models)
{
    // We'll grab the primary key name of the related models since it could be set to
    // a non-standard name and not "id". We will then construct the constraint for
    // our eagerly loading query so it returns the proper models from execution.
    $key = $this->getQualifiedOwnerKeyName();

    $whereIn = $this->whereInMethod($this->related, $this->ownerKey);

    $this->whereInEager($whereIn, $key, $this->getEagerModelKeys($models));
}

$key returns customers.id , the column we will ask by. $whereIn returns the name of the where-in method to use; park it for the scars. The last line ships the list to whereInEager in Relation.php:

vendor · Illuminate/Database/Eloquent/Relations/Relation.php
protected function whereInEager(string $whereIn, string $key, array $modelKeys, ?Builder $query = null)
{
    ($query ?? $this->query)->{$whereIn}($key, $modelKeys);

    if ($modelKeys === []) {
        $this->eagerKeysWereEmpty = true;
    }
}

It fires the method it was handed, with ('customers.id', [1]) , onto the customers query. Nothing has run yet; the question is only built. When it runs, it is the second query the log showed when with() first ran:

query log
select * from "customers" where "customers"."id" in (1)

One in , one id inside it, because the list was gathered and deduped before the query existed. Run the loop over three orders or a hundred and three: the id list collapses to the distinct customers those orders point at, and the question stays a single in . (The empty-list branch and its eagerKeysWereEmpty flag are a scar for later.)

Step two: run#

With the question built, the vendor pulls the trigger in getEager , same file:

vendor · Illuminate/Database/Eloquent/Relations/Relation.php
public function getEager()
{
    return $this->eagerKeysWereEmpty
        ? $this->related->newCollection()
        : $this->get();
}

Our list was not empty, so get() runs the in (1) query and returns one row: Alice. The customers table gets asked once, no matter how many orders were behind that [1] .

Step three: stitch#

Now match takes the row it got back and hangs it onto every order that asked:

vendor · Illuminate/Database/Eloquent/Relations/BelongsTo.php
public function match(array $models, EloquentCollection $results, $relation)
{
    // First we will get to build a dictionary of the child models by their primary
    // key of the relationship, then we can easily match the children back onto
    // the parents using that dictionary and the primary key of the children.
    $dictionary = [];

    foreach ($results as $result) {
        $attribute = $this->getDictionaryKey($this->getRelatedKeyFrom($result));

        if ($attribute !== null) {
            $dictionary[$attribute] = $result;
        }
    }

    // Once we have the dictionary constructed, we can loop through all the parents
    // and match back onto their children using these keys of the dictionary and
    // the primary key of the children to map them onto the correct instances.
    foreach ($models as $model) {
        $attribute = $this->getDictionaryKey($this->getForeignKeyFrom($model));

        if ($attribute !== null && isset($dictionary[$attribute])) {
            $model->setRelation($relation, $dictionary[$attribute]);
        }
    }

    return $models;
}

Two loops. The first builds a dictionary from the fetched rows, keyed by id, for us [1 => Alice] . The second walks the three orders, looks each one up by its customer_id , and calls setRelation to hang the match on it. All three point at 1, so all three get the same Alice.

Asked for once, attached three times. That is my $byId from the hand-rolled version, guards and all: the same Alice hung on every order that pointed at her.

The function that strings them together#

All three steps live inside one method. get() hands each relation named in with() to eagerLoadRelation in Builder.php:

vendor · Illuminate/Database/Eloquent/Builder.php
protected function eagerLoadRelation(array $models, $name, Closure $constraints)
{
    // First we will "back up" the existing where conditions on the query so we can
    // add our eager constraints. Then we will merge the wheres that were on the
    // query back to it in order that any where conditions might be specified.
    $relation = $this->getRelation($name);

    $relation->addEagerConstraints($models);

    $constraints($relation);

    // Once we have the results, we just match those back up to their parent models
    // using the relationship instance. Then we just return the finished arrays
    // of models which have been eagerly hydrated and are readied for return.
    return $relation->match(
        $relation->initRelation($models, $name),
        $relation->getEager(), $name
    );
}

The three verbs in order, some under the same names I gave them: addEagerConstraints on its own line, getEager tucked into the arguments, match as the return value. (getRelation looks up customer() , initRelation blanks each order's relation slot, and the $constraints closure is empty here.)

Collect, ask once, match: the vendor's order is my order.

That answers the title. with('customer') holds no memory and makes no guess. The orders it just loaded contain every customer_id it will need; it gathers that list before the first question to customers exists, dedupes it, ships it in one in , and stitches the answers back through a dictionary. That is eager loading, all of it. The list before the question is the entire trick, and why the count stays at two whether the loop runs over three orders or a hundred and three.

The scars#

The vendor has lines mine does not, and they read like scars: lines that exist only because something, once, went wrong exactly there. Each one is a healed wound, and each can be reopened on the bench. Start with the riddle from the top: in (1) , a literal, no placeholder. The answer is whereInMethod in Relation.php, the flavor picker addEagerConstraints called before shipping the list:

vendor · Illuminate/Database/Eloquent/Relations/Relation.php
protected function whereInMethod(Model $model, $key)
{
    return $model->getKeyName() === last(explode('.', $key))
        && in_array($model->getKeyType(), ['int', 'integer'])
            ? 'whereIntegerInRaw'
            : 'whereIn';
}

Primary key of type int, and the keys go in raw: whereIntegerInRaw , literals in the SQL. Anything else gets whereIn and placeholders. An integer cast by Eloquent cannot smuggle SQL in; a string can, so string keys pay for placeholders. And raw is cheaper: every ? is a bound parameter, drivers cap how many a query may carry, and eager loads over thousands of parents can hit the cap. My instinct to glue integers in raw was the vendor's own move, minus the type check I never did.

The second scar my query walks into blind: it never met an empty bench. No orders would mean an empty $ids and SQL ending in a bare in () , and my bet is that does not survive the parser:

tinker
> DB::select('select * from customers where id in ()');
   Illuminate\Database\QueryException  SQLSTATE[42601]: Syntax error: 7 ERROR:  syntax error at or near ")"
LINE 1: select * from customers where id in ()
                                             ^ (Connection: pgsql, Host: 127.0.0.1, Port: 5432, Database: laravel, SQL: select * from customers where id in ()).

Dead in Postgres's parser. So how does the vendor survive zero orders? Not through the parked flag; the guard sits one step earlier, in get() itself, back in Builder.php:

vendor · Illuminate/Database/Eloquent/Builder.php
public function get($columns = ['*'])
{
    $builder = $this->applyScopes();

    // If we actually found models we will also eager load any relationships that
    // have been specified as needing to be eager loaded, which will solve the
    // n+1 query issue for the developers to avoid running a lot of queries.
    if (count($models = $builder->getModels($columns)) > 0) {
        $models = $builder->eagerLoadRelations($models);
    }

    return $this->applyAfterQueryCallbacks(
        $builder->getModel()->newCollection($models)
    );
}

Zero rows from orders means the if never fires and the three steps never start. (The comment even names our disease.) So with() over zero orders should produce neither an error nor two queries, but one:

tinker
> DB::flushQueryLog();
= null
> App\Models\Order::with('customer')->where('id', '<', 0)->get();
= Illuminate\Database\Eloquent\Collection {#8485
    all: [],
  }
> array_column(DB::getQueryLog(), 'query');
= [
    "select * from \"orders\" where \"id\" < ?",
  ]

One query, orders alone, empty collection back. The customers table was never contacted. And the parked flag guards a deeper hole this bench cannot show: orders can exist while every customer_id is NULL . The is_null guard in the collector then hands whereInEager an empty list with parents still in play, eagerKeysWereEmpty goes up, and getEager answers with an empty collection instead of building the in () that killed my version. Two guards, two ways to an empty list, and my hand-rolled version had neither.

One loose end#

One thing has itched from the start. All session I typed Order::create() , Order::where() , Order::all() , Order::with() . Yet Order declares one method, customer() . The rest must live up in Model , and method_exists checks the full inheritance chain, so it should say true. create() built orders 2 and 3; of course it exists:

tinker
> method_exists(App\Models\Order::class, 'create');
= false
> method_exists(App\Models\Order::class, 'where');
= false

False. Twice. Neither create nor where exists on Order or on any class above it. And yet:

tinker
> method_exists(App\Models\Order::class, 'all');
= true

all() is real. So the session ran on a mixture: some statics I called are genuine methods, some are calls to methods no class has, and both kinds worked identically. Part real, part not is stranger than all of them being fake.

One last byte. If where() reaches no method, something still returned a value every time I called it. What came back?

tinker
> class_basename(App\Models\Order::where('id', '>', 0));
= "Builder"

A Builder . Not an Order , and not the product of any method Order has, because Order has no where . The call went out and something answered it.

Who picks up the phone is the next episode.


Next
Who picks up the phone
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