Metaprogramming
Late static binding: why self gave you the wrong class
The mistake
self and static look like two spellings of the same idea. Inside a class, both
seem to mean “this class”, so people pick whichever one they saw last. And for a
long time nothing goes wrong, because a class with nothing extending it has only one
answer to give. The two keywords disagree the moment someone writes
class User extends Model, and that person is usually not the person who wrote
self.
The machine
The class each keyword binds to, and what a wrapping call does to it, run on the tested reducer. Every case is checked against a real PHP 8.4 on each push, by scripts/verify-against-php.ts.
Drive it
The panel opens on one class. Model::make() returns new self(), and there is
nothing else for either keyword to resolve to.
- Resolve it, then add the subclass. The factory does not change. The call site
becomes
User::make(). You still get aModel. - Change
selftostatic. The call site does not move. One word in the factory does. Now you get aUser. - Wrap the call in
Model::make(). The factory still saysnew static()and you get aModelagain. Then switch the wrapper toparent::make()and theUsercomes back.
The mechanism
PHP keeps two classes in mind while it runs a method.
The first is the class the body is written in. self means that one, and it is
fixed when the file is written. Model::make() is defined in Model, so new self()
inside it builds a Model, and no call site can change that. This is early
binding: resolved from the source, before anything runs.
The second is the class that was called. static means that one. When you write
User::make(), PHP does not find make() on User, so it runs the copy on Model,
but it remembers that you named User. new static() reads that memory and builds a
User. PHP calls that late binding, because the class is not settled until the
call happens.
The rule covers more than new. self::class and static::class split the same
way, and so do constants: self::LABEL reads the constant on Model while
static::LABEL reads the one on User. Whatever the keyword binds to, every use of
it in that method sees the same class.
One more rule sits on top of those two. PHP does not work the called class out again
at each hop. It carries it from one static call to the next, and only some calls
carry it. static::, self:: and parent:: are forwarding calls: they
pass the remembered class along untouched. Naming a class does not. Writing
Model::make() sets the remembered class to Model, and everything downstream sees
Model.
So these two wrappers are not interchangeable, even though they run the same method:
public static function build()
{
return parent::make(); // User::build() gives you a User
}
public static function build()
{
return Model::make(); // User::build() gives you a Model
}
The factory is correct in both. The second wrapper throws the answer away one line
before it is used, and nothing in the file that contains new static() shows it.
Eloquent is built on this. Model::newInstance() in laravel/framework 13.7 is
$model = new static;, which is why hydrating a query result gives you back a User
and not a bare Model. Change that one word and every hydrated model would come back
as a Model.
In your code
A factory or a fluent constructor on a class other people extend wants static:
class Model
{
public static function make(): static
{
return new static();
}
}
class User extends Model {}
User::make(); // a User
The : static return type is worth adding on its own. It does not change what the
method returns, it checks it. With return new self() in the body, Model::make()
still works and User::make() raises a TypeError instead of handing back the wrong
class. A failure at the call is easier to read than a wrong object found three layers
later.
The fine print
staticis allowed as a return type and inside the body of a method. It is not allowed as a parameter type or a property type.- A method used through a trait binds to the class that uses the trait, not to the trait. Traits have their own rules and this page does not model them.
- A call through a variable class name,
$c::make(), does not forward the remembered class.forward_static_call()andforward_static_call_array()are how you keep it in that case. new parent()is legal and behaves likenew self(): a fixed class from the source, one step up.__callStaticis a different mechanism and lives on the magic methods page.selfis sometimes right. A singleton, or a class nobody is meant to extend, meansselfon purpose. The bug isselfby accident.
Further reading
- PHP: late static bindings is the reference, including the “forwarding call” section that the wrapper rule above comes from.
- PHP RFC: static return type explains
why
: staticwas added in PHP 8.0 and what it does and does not check. - Laravel: the base Model
is where
newInstancelives, thenew staticthat every hydrated model passes through.
Spotted a problem, or have a way to make this clearer? Suggest an improvement.