Metaprogramming

Magic methods: how $user->name works when there is no name

The mistake

An Eloquent model feels like it has a property for every column and a method for every query. $user->name reads the name, User::where(...) builds a query, and it all looks like ordinary object code. So when people meet __get and __call, the guess is that these magic methods sit in front of every access and intercept it.

They do not. PHP tries the ordinary thing first: it looks for a real, accessible property or method, and only when there is none does it fall back to a magic method. Magic is the last resort, not the front door. Once you see the order, the model stops being mysterious: it has almost no real properties or methods for its columns and queries at all. It is a small shell that catches the misses and dispatches them.

The machine

Simulator · Magic methods

The resolution order, what resolves each access and the result run on the tested reducer, matching how PHP falls back to magic methods only when ordinary lookup fails.

Reading $user->name. It looks like a property, but User declares no such property. Resolve it and see.

Drive it

The panel boots on $user->name. The model declares no $name property, so watch where the access goes.

  • Resolve it. PHP looks for a declared $name, finds none, and falls through to __get('name'), which reads the attributes bag. The column was never a property.
  • Try where(), then save(). where() is not a method either: it falls through to __call and forwards to a query builder. But save() is a real method on Model, so it runs directly and no magic happens. Real members win.
  • Declare a real public $name, then read the column again. Now the declared property is found first, __get never runs, and the row’s value is never read. One stray property silently breaks the attribute.

The mechanism

For a method call, PHP resolves $obj->method(...) like this. It searches the class and its parents for a method that is defined and accessible from the calling scope. If it finds one, it calls it, and that is the end of the story. Only if there is no such method does PHP call __call('method', $args), if the class defines it. With neither, you get Error: Call to undefined method. Static calls work the same way through __callStatic.

Property access is the same shape. $obj->prop returns a declared, accessible property if there is one. An undefined or inaccessible property falls through to __get('prop'); writing one falls through to __set('prop', $value). The word “inaccessible” matters: reading a private property from outside the class also triggers __get, because from that scope the real property cannot be seen.

That fallback is the entire machinery of an Eloquent model. Its columns are not declared properties, they live in a $attributes array, so every $user->name misses the property lookup and lands in __get, which calls getAttribute and reads the array (or runs an accessor, or loads a relation). Its query methods are not declared either, so $user->where(...) misses and lands in __call, which forwards to a fresh query builder. But save, delete, fresh and the rest are real methods on the Model base class, so they resolve directly and never touch __call. The model looks like it has hundreds of members. It has a handful, plus two magic methods that fake the rest.

Which is exactly why shadowing causes trouble. Declare a real property whose name matches a column, and the property lookup now succeeds, so __get is never reached and the column value never loads.

In your code

The pattern on its own is small. A class catches unknown reads with __get and unknown calls with __call, while its real methods stay real:

class Config
{
    private array $items = ['driver' => 'mysql'];

    public function __get(string $key): mixed
    {
        return $this->items[$key] ?? null;
    }

    public function reload(): void { /* a real, declared method */ }
}

$c = new Config();
$c->driver;   // 'mysql' via __get: there is no declared $driver
$c->reload(); // runs directly: __call is never consulted

Eloquent is this pattern at scale, and the shadowing bug is a real one:

class User extends Model
{
    public $name; // don't: this declares a real property that shadows the column
}

$user = User::find(1);
$user->name;  // null, not the row value: the real property shadowed __get

The fix is to not declare it. A model’s columns are meant to live in the attributes bag and be reached through __get, so the property you were about to add is the bug.

The fine print

  • This page shows __get, __set and __call. The family is larger: __callStatic for static calls, __isset and __unset for isset() and unset(), __invoke for calling an object like a function, and __toString.
  • Magic dispatch is slower than a real member and invisible to IDEs and static analysis, which cannot see a property that does not exist in the source. Laravel leans on @property and @method docblocks, generated model helpers, so your tools know what a model appears to have.
  • PHP walks the whole inheritance chain for a real member before it ever considers a magic method. __call really is the last thing tried.
  • __get returns by value. Trying to modify what it returns in place, like $model->items[] = 'x' when items comes from __get, raises an “indirect modification of overloaded property” notice, because there is no real property to modify.

Further reading

Spotted a problem, or have a way to make this clearer? Suggest an improvement.