Skip to content

makeEmpty/make silently loses property values for PHP 8.4+ hooked properties #61

Description

@petrdobr

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions