Who picks up the phone
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:
<?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);
}
}
<?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:
> [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
:
> 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:
> 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:
> $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:
<?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:
> 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:
> 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:
<?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:
> 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
:
> 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:
> 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):
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:
> 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):
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):
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):
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:
> 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:
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:
> 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:
> 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):
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:
> 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.
- 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