>Dudenkoff_
← Lab / Laravel · Eloquent

Who picks up the phone

Laravel 13 · PHP 8.5 · Postgres · July 20, 2026 · part 3 of 8

The last episode ended on a loose end. All session I had called Order::where() , Order::create() , Order::all() ; the first two exist as methods nowhere, the third is real, and every one of them worked the same. The last call of the session handed back a Builder . Who picks up the phone? Same bench as before. This time one question, and a single moving part to watch.

For anyone landing here first, the two models are small. Order declares one method, a relation; Customer declares nothing:

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);
    }
}
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
{
    //
}

The same 101 customers and 103 orders the last episode built:

tinker
> [App\Models\Customer::count(), App\Models\Order::count()];
= [
    101,
    103,
  ]

And the two facts I left the last episode on. Order has no where , yet where comes back a Builder :

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

The class does not own the method. The call works, and returns a query builder. Something on Order answers a call to a method that is not there.

What plain PHP does#

First, what happens with nothing in the way, a class with no methods and a static call it cannot satisfy:

tinker
> class Plain {}
> Plain::where(1);
   Error  Call to undefined method Plain::where().

PHP's default is death. A static call to a method that does not exist is a fatal error, right there. Order takes the same shape of call and lives, so something on Order catches what kills Plain . Two facts, one target.

The first bet: one shared query#

The obvious answer: something hands back a query object, and the cheapest version is one shared object. Before I build it, one fact about a query builder, and a note on reading these lines. Several statements on a line run in order, and the session echoes only the last value. So this prints the final toSql() , after two where calls land on one stored builder:

tinker
> $q = DB::table('orders'); $q->where('total', '>', 100); $q->where('id', 1); $q->toSql();
= "select * from \"orders\" where \"total\" > ? and \"id\" = ?"

Both conditions stuck. A kept builder accumulates: where mutates it in place and returns it. Hold that. Here is the shared-query bet, a static where that builds one table query and hands it back:

app/Naive/StaticOrder.php
<?php

namespace App\Naive;

use Illuminate\Support\Facades\DB;

class StaticOrder
{
    public static $query;

    public static function where(...$args)
    {
        static::$query ??= DB::table('orders');

        return static::$query->where(...$args);
    }
}

One $query , built on first use, reused after. The first call should look right. Watch the second:

tinker
> App\Naive\StaticOrder::where('total', '>', 100)->toSql();
= "select * from \"orders\" where \"total\" > ?"
> App\Naive\StaticOrder::where('id', 1)->toSql();
= "select * from \"orders\" where \"total\" > ? and \"id\" = ?"

The first is clean. The second was meant to ask about id alone, and it drags total along from the call before it, because both calls wrote to the one stored builder. The real model does not do this:

tinker
> App\Models\Order::where('total', '>', 100)->toSql();
= "select * from \"orders\" where \"total\" > ?"
> App\Models\Order::where('id', 1)->toSql();
= "select * from \"orders\" where \"id\" = ?"

Each call to the real Order starts clean. So whatever answers on the real model has to birth a fresh query every time, not reuse one. But Order has no where to birth it. What does?

The second bet: a magic method#

PHP has a hook for exactly this case. A static call to a method a class does not define does not have to die; the class can catch it with a magic method and decide what to do. The smallest thing that catches the miss and forwards it to a fresh DB::table('orders') every time:

app/Naive/MagicOrder.php
<?php

namespace App\Naive;

use Illuminate\Support\Facades\DB;

class MagicOrder
{
    public static function __callStatic($method, $parameters)
    {
        return DB::table('orders')->$method(...$parameters);
    }
}

Fresh table each call, so the shared-builder dirt cannot happen. It holds:

tinker
> App\Naive\MagicOrder::where('total', '>', 100)->toSql();
= "select * from \"orders\" where \"total\" > ?"
> App\Naive\MagicOrder::where('id', 1)->toSql();
= "select * from \"orders\" where \"id\" = ?"

Clean twice, the first death gone. And it fools method_exists exactly as the real model did, and returns something whose short name is Builder , same as Order :

tinker
> method_exists(App\Naive\MagicOrder::class, 'where');
= false
> class_basename(App\Naive\MagicOrder::where('id', 1));
= "Builder"

Three of the real model's answers reproduced: no method, a call that still works, a Builder back. The bet looks fully won. One thing separates them. Ask each builder for a row:

tinker
> App\Naive\MagicOrder::where('id', 1)->first();
= {#7458
    +"id": 1,
    +"customer_id": 1,
    +"total": 100,
    +"created_at": "2026-07-20 04:59:58",
    +"updated_at": "2026-07-20 04:59:58",
  }
> App\Models\Order::where('id', 1)->first();
= App\Models\Order {#8482
    id: 1,
    customer_id: 1,
    total: 100,
    created_at: "2026-07-20 04:59:58",
    updated_at: "2026-07-20 04:59:58",
  }

My hook answers with a bare row, a stdClass with quoted keys. The real model answers with an App\Models\Order . Same columns, different thing wrapped around them. So inside the real one it is not DB::table . What is it?

Open the box#

The behavior is pinned. Now the vendor. My magic method reproduced three of the real model's answers and missed the fourth: it returns rows, not models. The real chain is longer than one hook, and it reads top to bottom.

The hatch: __callStatic#

A static miss on a model lands here first, in Model::__callStatic (Model.php):

vendor · Illuminate/Database/Eloquent/Model.php
public static function __callStatic($method, $parameters)
{
    if (static::isScopeMethodWithAttribute($method)) {
        return static::query()->$method(...$parameters);
    }

    return (new static)->$method(...$parameters);
}

The scope branch on the first line is not our case; where is not a scope, so control falls to the last line. That line is my hook plus one move I never made: (new static) . Instead of forwarding to a shared table, it builds a fresh Order and calls the method on that instance. Which means there is a second hook, on the instance itself. I can set it off by hand:

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

A Builder again, this time from an instance, off a method the instance does not have. So the instance carries its own catch, Model::__call (Model.php):

vendor · Illuminate/Database/Eloquent/Model.php
public function __call($method, $parameters)
{
    if (in_array($method, ['increment', 'decrement', 'incrementQuietly', 'decrementQuietly', 'incrementEach', 'decrementEach', 'incrementEachQuietly', 'decrementEachQuietly'])) {
        return $this->$method(...$parameters);
    }

    if ($resolver = $this->relationResolver(static::class, $method)) {
        return $resolver($this);
    }

    if (Str::startsWith($method, 'through') &&
        method_exists($this, $relationMethod = (new SupportStringable($method))->after('through')->lcfirst()->toString())) {
        return $this->through($relationMethod);
    }

    return $this->forwardCallTo($this->newQuery(), $method, $parameters);
}

Trace where down it. The first branch tests the name against increment and its family; where is not in the list, so it skips (that branch is a loaded gun for later). No relation resolver by that name, no through prefix. The last line runs, with our values: forwardCallTo($this->newQuery(), 'where', ['id', 1]) . And newQuery is where the fresh query is born (Model.php):

vendor · Illuminate/Database/Eloquent/Model.php
public function newQuery()
{
    return $this->registerGlobalScopes($this->newQueryWithoutScopes());
}

A fresh Eloquent Builder on every call. That is the clean start the real Order showed in the first bet: no shared object, nothing to accumulate. And this is not DB::table 's plain query builder; it is the Eloquent one, and it hydrates the rows it fetches into models. That is the row-versus-model split my hook could not cross. The last body is the forwarding itself (ForwardsCalls.php):

vendor · Illuminate/Support/Traits/ForwardsCalls.php
protected function forwardCallTo($object, $method, $parameters)
{
    try {
        return $object->{$method}(...$parameters);
    } catch (Error|BadMethodCallException $e) {
        $pattern = '~^Call to undefined method (?P<class>[^:]+)::(?P<method>[^\(]+)\(\)$~';

        if (! preg_match($pattern, $e->getMessage(), $matches)) {
            throw $e;
        }

        if ($matches['class'] != get_class($object) ||
            $matches['method'] != $method) {
            throw $e;
        }

        static::throwBadMethodCallException($method);
    }
}

The working line is the first one in the try : $object->{$method}(...) runs Builder->where('id', 1) . The builder applies the condition and returns itself. That return value flies back out through __call , out through __callStatic , and lands in my session as the Builder from the top. The catch block below it is a scar; park it.

So Order carries two kinds of static. The misses, where and create , exist as methods nowhere; the hatch __callStatic catches each one, builds a fresh instance, forwards through __call to a fresh Eloquent Builder , and returns it. The hits are the other kind. all() from the last episode is a real method, and so is query() ; but both run the same move underneath, a fresh instance and its newQuery . Faked or real, both roads end at a fresh Builder . Plain died at the start because it had no hatch to catch the miss. Order has one, so the call never reached the error at all.

The scars#

Two threads are left hanging: the catch block parked in forwardCallTo , and all() , the call from the lead that was real all along. The catch block first. You have met its work every time you misspelled a method on a model: the error names the model you called, not the builder that actually missed. That accuracy costs lines my hook does not have. Call a method neither of us defines, on the real model:

tinker
> App\Models\Order::flibber();
   BadMethodCallException  Call to undefined method App\Models\Order::flibber().

The one that missed was the query builder, several forwards deep. The message names App\Models\Order . Something rewrote the name on the way out. The catch block in forwardCallTo is where. It reads the "Call to undefined method" text, checks the miss was really the object it forwarded to and really this method, and if both hold, throws its own exception (ForwardsCalls.php) with static::class in the name:

vendor · Illuminate/Support/Traits/ForwardsCalls.php
protected static function throwBadMethodCallException($method)
{
    throw new BadMethodCallException(sprintf(
        'Call to undefined method %s::%s()', static::class, $method
    ));
}

The first throw comes from the query builder, naming Illuminate\Database\Query\Builder::flibber() . Then one trait body, forwardCallTo , runs the relay twice: the Eloquent Builder catches that throw and rewrites the name to itself, then the Model catches the next one and rewrites it to Order . Each level signs the miss with its own class. My hook does none of this; it forwards once to a plain table and lets the raw first link out:

tinker
> App\Naive\MagicOrder::flibber();
   BadMethodCallException  Call to undefined method Illuminate\Database\Query\Builder::flibber().

Illuminate\Database\Query\Builder::flibber() , the name before any rewrite. My version shows the first link of the chain; the vendor carries the name up to the class you actually called.

Then the second thread, the door I walked past. Is there a plain static that opens a query on purpose, no miss involved? There is, and I never called it once this session:

tinker
> method_exists(App\Models\Order::class, 'query');
= true
> class_basename(App\Models\Order::query());
= "Builder"

query() is a real method, and it returns a Builder . Its body is the honest version of the whole trick (Model.php):

vendor · Illuminate/Database/Eloquent/Model.php
public static function query()
{
    return (new static)->newQuery();
}

(new static)->newQuery() : a fresh instance, a fresh Eloquent Builder , no hatch anywhere in it. That is the family all() belongs to. The last episode ended on a puzzle, some statics real and some faked, all working the same. Here it resolves. The real ones are thin wrappers over newQuery ; the fakes are misses the hatch routes to the same newQuery . Two doors, one room.

The call it will not forward#

One line in __call has sat untouched: the first branch, the one that tests the method against increment and its family and returns before any forwarding. Every faked static I chased answers method_exists with false . This name does not:

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

increment is a real method on the model, and __call catches its whole family on the first line, ahead of the builder it hands everything else to. The builder was not trusted with it. What is it about adding one to a column that the model keeps for itself instead of forwarding? The ten that vanished is the next episode.


Next
The ten that vanished
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