Environment
- PHP: 8.5.6 (reproduces on PHP 8.4+, the version that introduced property hooks)
- codeception/stub: 4.3.0
- phpunit/phpunit: 13.0.6
Problem
When mocking a class that has PHP 8.4 property hooks, values passed to makeEmpty() / make() for hooked properties are silently lost. Depending on the property type, this either returns null / an auto-generated stub, or throws a RuntimeException if the property type is a final class.
A real-world trigger: symfony/http-foundation 8.1 added set hooks with deprecation warnings to all major public bags on Request ($request, $query, $attributes, etc.), causing all tests that mock Request with those properties to break.
Minimal reproduction
class Foo
{
public string $bar {
set { $this->bar = $value; }
}
}
// Value is silently lost
$mock = $this->makeEmpty(Foo::class, ['bar' => 'baz']);
var_dump($mock->bar); // expected: string(3) baz, actual: auto-stub or error
With a final-typed property (e.g. InputBag):
// Throws RuntimeException:
// Return value for MockObject_Request::::get() cannot be generated:
// Class "Symfony\Component\HttpFoundation\InputBag" is declared "final"
$mock = $this->makeEmpty(Request::class, ['request' => new InputBag(['foo' => 'bar'])]);
$mock->request->get('foo'); // boom
Root cause
Stub::bindParameters() deliberately switches to getParentClass() for PHPUnit mock objects (line 523–526 in Stub.php), then calls ReflectionProperty::setValue() on the parent's reflection:
if ($mock instanceof PHPUnitMockObject) {
$reflectionClass = $reflectionClass->getParentClass(); // Request::class
}
// ...
$reflectionProperty->setValue($mock, $value); // writes to parent's backing store
In PHP 8.4, a class that redeclares a property with hooks (as PHPUnit's mock generator does when the source class has hooks — see phpunit#6549) gets its own separate backing store. The write goes into the parent's backing store; the read goes through the mock's generated get hook, which reads from the mock's backing store — they never meet.
Possible fix direction
For properties that have hooks in the source class, bindParameters() should configure the mock via PHPUnit's mock API instead of ReflectionProperty::setValue():
// PHPUnit 13 exposes hooked properties as mockable methods named '$prop::get'
$mock->method('$bar::get')->willReturn('baz');
This requires detecting hooked properties (via ReflectionProperty::hasHooks() / getHook(), available since PHP 8.4) and branching accordingly.
Environment
Problem
When mocking a class that has PHP 8.4 property hooks, values passed to
makeEmpty()/make()for hooked properties are silently lost. Depending on the property type, this either returnsnull/ an auto-generated stub, or throws aRuntimeExceptionif the property type is afinalclass.A real-world trigger: symfony/http-foundation 8.1 added
sethooks with deprecation warnings to all major public bags onRequest($request,$query,$attributes, etc.), causing all tests that mockRequestwith those properties to break.Minimal reproduction
With a
final-typed property (e.g.InputBag):Root cause
Stub::bindParameters()deliberately switches togetParentClass()for PHPUnit mock objects (line 523–526 inStub.php), then callsReflectionProperty::setValue()on the parent's reflection:In PHP 8.4, a class that redeclares a property with hooks (as PHPUnit's mock generator does when the source class has hooks — see phpunit#6549) gets its own separate backing store. The write goes into the parent's backing store; the read goes through the mock's generated
gethook, which reads from the mock's backing store — they never meet.Possible fix direction
For properties that have hooks in the source class,
bindParameters()should configure the mock via PHPUnit's mock API instead ofReflectionProperty::setValue():This requires detecting hooked properties (via
ReflectionProperty::hasHooks()/getHook(), available since PHP 8.4) and branching accordingly.