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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions