Repository: php/php-src (2025-07-30T02:37:39Z)

Issue:
Reflection: have some indication of property hooks in `_property_string()`
### Description

The following code (https://3v4l.org/1fUfI)

```php
<?php

interface IHookedDemo {
    public mixed $prop1 { get; }
    public mixed $prop2 { set; }
    public mixed $prop3 { get; set; }
}
abstract class HookedDemo {
    abstract public mixed $prop1 { get; }
    abstract public mixed $prop2 { set; }
    abstract public mixed $prop3 { get; set; }
}
class WithHooks {
    public mixed $prop1 {
        get => "always this string";
    }
    public mixed $prop2 {
        set => strtolower($value);
    }
    public mixed $prop3 {
        get => $this->prop3;
        set => strtolower($value);
    }
}
class WithFinalHooks {
    public mixed $prop1 {
        final get => "always this string";
    }
    public mixed $prop2 {
        final set => strtolower($value);
    }
    public mixed $prop3 {
        final get => $this->prop3;
        final set => strtolower($value);
    }
}
$classes = [
    IHookedDemo::class,
    HookedDemo::class,
    WithHooks::class,
    WithFinalHooks::class,
];
foreach ( $classes as $clazz ) {
    echo "$clazz:\n";
    $ref = new ReflectionClass( $clazz );
    echo $ref;
    foreach ( [ 'prop1', 'prop2', 'prop3' ] as $prop ) {
        $propRef = new ReflectionProperty( $clazz, $prop );
        echo $propRef;
    }
    echo "\n";
}
```

Resulted in this output:
```
IHookedDemo:
Interface [ <user> <iterateable> interface IHookedDemo ] {
  @@ /in/1fUfI 3-7

  - Constants [0] {
  }

  - Static properties [0] {
  }

  - Static methods [0] {
  }

  - Properties [3] {
    Property [ public mixed $prop1 ]
    Property [ public mixed $prop2 ]
    Property [ public mixed $prop3 ]
  }

  - Methods [0] {
  }
}
Property [ public mixed $prop1 ]
Property [ public mixed $prop2 ]
Property [ public mixed $prop3 ]

HookedDemo:
Class [ <user> <iterateable> abstract class HookedDemo ] {
  @@ /in/1fUfI 8-12

  - Constants [0] {
  }

  - Static properties [0] {
  }

  - Static methods [0] {
  }

  - Properties [3] {
    Property [ public mixed $prop1 ]
    Property [ public mixed $prop2 ]
    Property [ public mixed $prop3 ]
  }

  - Methods [0] {
  }
}
Property [ public mixed $prop1 ]
Property [ public mixed $prop2 ]
Property [ public mixed $prop3 ]

WithHooks:
Class [ <user> <iterateable> class WithHooks ] {
  @@ /in/1fUfI 13-24

  - Constants [0] {
  }

  - Static properties [0] {
  }

  - Static methods [0] {
  }

  - Properties [3] {
    Property [ public mixed $prop1 ]
    Property [ public mixed $prop2 ]
    Property [ public mixed $prop3 ]
  }

  - Methods [0] {
  }
}
Property [ public mixed $prop1 ]
Property [ public mixed $prop2 ]
Property [ public mixed $prop3 ]

WithFinalHooks:
Class [ <user> <iterateable> class WithFinalHooks ] {
  @@ /in/1fUfI 25-36

  - Constants [0] {
  }

  - Static properties [0] {
  }

  - Static methods [0] {
  }

  - Properties [3] {
    Property [ public mixed $prop1 ]
    Property [ public mixed $prop2 ]
    Property [ public mixed $prop3 ]
  }

  - Methods [0] {
  }
}
Property [ public mixed $prop1 ]
Property [ public mixed $prop2 ]
Property [ public mixed $prop3 ]
```

But I expected this output instead:
```
[similar but with some indication of what properties have hooks]
```

In #17827 I added an indication for abstract and final properties, and was told that the missing indication was a bug, which is why I've also filed this as a bug.

### PHP Version

8.4+

### Operating System

_No response_


Discussion:
CC @iluuu1994 
I figured before sending a patch it might be worth discussing how things should be shown - I propose something like
```
Property [ public mixed $prop1 { get; } ]
Property [ public mixed $prop2 { set; }]
Property [ public mixed $prop3 { get; set; } ]

Property [ public mixed $prop1 { final get; } ]
Property [ public mixed $prop2 { final set; }]
Property [ public mixed $prop3 { final get; final set; } ]
```
to indicate the presence (or requirement, for interfaces and abstract cases) of property hooks, and if they are final or not
Would be ok for me. I'm unsure whether this should also indicate "virtual", because otherwise `public $prop { get; }` becomes ambiguous. Is this a backed property and `set` is a valid, implicit operation, or is `set` forbidden? `public virtual $prop { get; }` would disambiguate.
> Would be ok for me. I'm unsure whether this should also indicate "virtual", because otherwise `public $prop { get; }` becomes ambiguous. Is this a backed property and `set` is a valid, implicit operation, or is `set` forbidden? `public virtual $prop { get; }` would disambiguate.

Okay, I'll put the `virtual` before the property type. Should this be for 8.4 or master?
`master` should be fine. This seems like an improvement rather than a bug fix.



Patch:
diff --git a/NEWS b/NEWS
index 61c39295db5bd..190c10fbba893 100644
--- a/NEWS
+++ b/NEWS
@@ -2,6 +2,9 @@ PHP                                                                        NEWS
 |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
 ?? ??? ????, PHP 8.5.0beta1
 
+- Reflection:
+  . Fixed bug GH-17927 (Reflection: have some indication of property hooks in
+    `_property_string()`). (DanielEScherzer)
 
 31 Jul 2025, PHP 8.5.0alpha3
 
diff --git a/UPGRADING b/UPGRADING
index e21eda5439a9e..c3641184f5dea 100644
--- a/UPGRADING
+++ b/UPGRADING
@@ -394,6 +394,11 @@ PHP 8.5 UPGRADE NOTES
   . The output of ReflectionClass::toString() for enums has changed to
     better indicate that the class is an enum, and that the enum cases
     are enum cases rather than normal class constants.
+  . The output of ReflectionProperty::__toString() for properties with
+    hooks has changed to indicate what hooks the property has, whether those
+    hooks are final, and whether the property is virtual. This also affects 
+    the output of ReflectionClass::__toString() when a class contains hooked
+    properties.
 
 - Session:
   . session_start is stricter in regard to the option argument.
diff --git a/ext/reflection/php_reflection.c b/ext/reflection/php_reflection.c
index 3a37304dc2dc5..bc9f06e5e1a3c 100644
--- a/ext/reflection/php_reflection.c
+++ b/ext/reflection/php_reflection.c
@@ -1037,6 +1037,9 @@ static void _property_string(smart_str *str, zend_property_info *prop, const cha
 		if (prop->flags & ZEND_ACC_READONLY) {
 			smart_str_appends(str, "readonly ");
 		}
+		if (prop->flags & ZEND_ACC_VIRTUAL) {
+			smart_str_appends(str, "virtual ");
+		}
 		if (ZEND_TYPE_IS_SET(prop->type)) {
 			zend_string *type_str = zend_type_to_string(prop->type);
 			smart_str_append(str, type_str);
@@ -1054,6 +1057,26 @@ static void _property_string(smart_str *str, zend_property_info *prop, const cha
 			smart_str_appends(str, " = ");
 			format_default_value(str, default_value);
 		}
+		if (prop->hooks != NULL) {
+			smart_str_appends(str, " {");
+			const zend_function *get_hooked = prop->hooks[ZEND_PROPERTY_HOOK_GET];
+			if (get_hooked != NULL) {
+				if (get_hooked->common.fn_flags & ZEND_ACC_FINAL) {
+					smart_str_appends(str, " final get;");
+				} else {
+					smart_str_appends(str, " get;");
+				}
+			}
+			const zend_function *set_hooked = prop->hooks[ZEND_PROPERTY_HOOK_SET];
+			if (set_hooked != NULL) {
+				if (set_hooked->common.fn_flags & ZEND_ACC_FINAL) {
+					smart_str_appends(str, " final set;");
+				} else {
+					smart_str_appends(str, " set;");
+				}
+			}
+			smart_str_appends(str, " }");
+		}
 	}
 
 	smart_str_appends(str, " ]\n");


Test patch:
diff --git a/Zend/tests/lazy_objects/skipLazyInitialization.phpt b/Zend/tests/lazy_objects/skipLazyInitialization.phpt
index d4d564d9c6532..4fc47b13db671 100644
--- a/Zend/tests/lazy_objects/skipLazyInitialization.phpt
+++ b/Zend/tests/lazy_objects/skipLazyInitialization.phpt
@@ -227,7 +227,7 @@ getValue(): NULL
 setRawValueWithoutLazyInitialization():
 getValue(): string(5) "value"
 
-## Property [ public $hooked = NULL ]
+## Property [ public $hooked = NULL { get; set; } ]
 
 skipInitializerForProperty():
 getValue(): NULL
@@ -235,7 +235,7 @@ getValue(): NULL
 setRawValueWithoutLazyInitialization():
 getValue(): string(5) "value"
 
-## Property [ public $virtual ]
+## Property [ public virtual $virtual { get; set; } ]
 
 skipInitializerForProperty():
 ReflectionException: Can not use skipLazyInitialization on virtual property A::$virtual
@@ -324,7 +324,7 @@ getValue(): NULL
 setRawValueWithoutLazyInitialization():
 getValue(): string(5) "value"
 
-## Property [ public $hooked = NULL ]
+## Property [ public $hooked = NULL { get; set; } ]
 
 skipInitializerForProperty():
 getValue(): NULL
@@ -332,7 +332,7 @@ getValue(): NULL
 setRawValueWithoutLazyInitialization():
 getValue(): string(5) "value"
 
-## Property [ public $virtual ]
+## Property [ public virtual $virtual { get; set; } ]
 
 skipInitializerForProperty():
 ReflectionException: Can not use skipLazyInitialization on virtual property A::$virtual
diff --git a/ext/reflection/tests/ReflectionClass_toString_008.phpt b/ext/reflection/tests/ReflectionClass_toString_008.phpt
new file mode 100644
index 0000000000000..15131cfd880a0
--- /dev/null
+++ b/ext/reflection/tests/ReflectionClass_toString_008.phpt
@@ -0,0 +1,165 @@
+--TEST--
+Using ReflectionClass::__toString() with hooked properties (GH-17927)
+--FILE--
+<?php
+
+interface IHookedDemo {
+	public mixed $getOnly { get; }
+	public mixed $setOnly { set; }
+	public mixed $both { get; set; }
+}
+abstract class HookedDemo {
+	abstract public mixed $getOnly { get; }
+	abstract public mixed $setOnly { set; }
+	abstract public mixed $both { get; set; }
+}
+class WithHooks {
+	public mixed $getOnly {
+		get => "always this string";
+	}
+	public mixed $setOnly {
+		set => strtolower($value);
+	}
+	public mixed $both {
+		get => $this->prop3;
+		set => strtolower($value);
+	}
+}
+class WithFinalHooks {
+	public mixed $getOnly {
+		final get => "always this string";
+	}
+	public mixed $setOnly {
+		final set => strtolower($value);
+	}
+	public mixed $both {
+		final get => $this->prop3;
+		final set => strtolower($value);
+	}
+}
+class WithMixedHooks {
+	public mixed $getIsFinal {
+		final get => "always this string";
+		set => strtolower($value);
+	}
+	public mixed $setIsFinal {
+		get => $this->setIsFinal;
+		final set => strtolower($value);
+	}
+}
+$classes = [
+	IHookedDemo::class,
+	HookedDemo::class,
+	WithHooks::class,
+	WithFinalHooks::class,
+	WithMixedHooks::class,
+];
+foreach ( $classes as $clazz ) {
+	echo new ReflectionClass( $clazz );
+}
+?>
+--EXPECTF--
+Interface [ <user> <iterateable> interface IHookedDemo ] {
+  @@ %s %d-%d
+
+  - Constants [0] {
+  }
+
+  - Static properties [0] {
+  }
+
+  - Static methods [0] {
+  }
+
+  - Properties [3] {
+    Property [ abstract public virtual mixed $getOnly { get; } ]
+    Property [ abstract public virtual mixed $setOnly { set; } ]
+    Property [ abstract public virtual mixed $both { get; set; } ]
+  }
+
+  - Methods [0] {
+  }
+}
+Class [ <user> <iterateable> abstract class HookedDemo ] {
+  @@ %s %d-%d
+
+  - Constants [0] {
+  }
+
+  - Static properties [0] {
+  }
+
+  - Static methods [0] {
+  }
+
+  - Properties [3] {
+    Property [ abstract public virtual mixed $getOnly { get; } ]
+    Property [ abstract public virtual mixed $setOnly { set; } ]
+    Property [ abstract public virtual mixed $both { get; set; } ]
+  }
+
+  - Methods [0] {
+  }
+}
+Class [ <user> <iterateable> class WithHooks ] {
+  @@ %s %d-%d
+
+  - Constants [0] {
+  }
+
+  - Static properties [0] {
+  }
+
+  - Static methods [0] {
+  }
+
+  - Properties [3] {
+    Property [ public virtual mixed $getOnly { get; } ]
+    Property [ public mixed $setOnly { set; } ]
+    Property [ public mixed $both { get; set; } ]
+  }
+
+  - Methods [0] {
+  }
+}
+Class [ <user> <iterateable> class WithFinalHooks ] {
+  @@ %s %d-%d
+
+  - Constants [0] {
+  }
+
+  - Static properties [0] {
+  }
+
+  - Static methods [0] {
+  }
+
+  - Properties [3] {
+    Property [ public virtual mixed $getOnly { final get; } ]
+    Property [ public mixed $setOnly { final set; } ]
+    Property [ public mixed $both { final get; final set; } ]
+  }
+
+  - Methods [0] {
+  }
+}
+Class [ <user> <iterateable> class WithMixedHooks ] {
+  @@ %s %d-%d
+
+  - Constants [0] {
+  }
+
+  - Static properties [0] {
+  }
+
+  - Static methods [0] {
+  }
+
+  - Properties [2] {
+    Property [ public mixed $getIsFinal { final get; set; } ]
+    Property [ public mixed $setIsFinal { get; final set; } ]
+  }
+
+  - Methods [0] {
+  }
+}
diff --git a/ext/reflection/tests/ReflectionProperty_toString_001.phpt b/ext/reflection/tests/ReflectionProperty_toString_001.phpt
new file mode 100644
index 0000000000000..0af0283bb5069
--- /dev/null
+++ b/ext/reflection/tests/ReflectionProperty_toString_001.phpt
@@ -0,0 +1,89 @@
+--TEST--
+Using ReflectionProperty::__toString() with hooked properties (GH-17927)
+--FILE--
+<?php
+
+interface IHookedDemo {
+	public mixed $getOnly { get; }
+	public mixed $setOnly { set; }
+	public mixed $both { get; set; }
+}
+abstract class HookedDemo {
+	abstract public mixed $getOnly { get; }
+	abstract public mixed $setOnly { set; }
+	abstract public mixed $both { get; set; }
+}
+class WithHooks {
+	public mixed $getOnly {
+		get => "always this string";
+	}
+	public mixed $setOnly {
+		set => strtolower($value);
+	}
+	public mixed $both {
+		get => $this->prop3;
+		set => strtolower($value);
+	}
+}
+class WithFinalHooks {
+	public mixed $getOnly {
+		final get => "always this string";
+	}
+	public mixed $setOnly {
+		final set => strtolower($value);
+	}
+	public mixed $both {
+		final get => $this->prop3;
+		final set => strtolower($value);
+	}
+}
+class WithMixedHooks {
+	public mixed $getIsFinal {
+		final get => "always this string";
+		set => strtolower($value);
+	}
+	public mixed $setIsFinal {
+		get => $this->setIsFinal;
+		final set => strtolower($value);
+	}
+}
+$classes = [
+	IHookedDemo::class,
+	HookedDemo::class,
+	WithHooks::class,
+	WithFinalHooks::class,
+	WithMixedHooks::class,
+];
+foreach ( $classes as $clazz ) {
+	echo "$clazz:\n";
+	$ref = new ReflectionClass( $clazz );
+	foreach ( $ref->getProperties() as $prop ) {
+		echo $prop;
+	}
+	echo "\n";
+}
+?>
+--EXPECT--
+IHookedDemo:
+Property [ abstract public virtual mixed $getOnly { get; } ]
+Property [ abstract public virtual mixed $setOnly { set; } ]
+Property [ abstract public virtual mixed $both { get; set; } ]
+
+HookedDemo:
+Property [ abstract public virtual mixed $getOnly { get; } ]
+Property [ abstract public virtual mixed $setOnly { set; } ]
+Property [ abstract public virtual mixed $both { get; set; } ]
+
+WithHooks:
+Property [ public virtual mixed $getOnly { get; } ]
+Property [ public mixed $setOnly { set; } ]
+Property [ public mixed $both { get; set; } ]
+
+WithFinalHooks:
+Property [ public virtual mixed $getOnly { final get; } ]
+Property [ public mixed $setOnly { final set; } ]
+Property [ public mixed $both { final get; final set; } ]
+
+WithMixedHooks:
+Property [ public mixed $getIsFinal { final get; set; } ]
+Property [ public mixed $setIsFinal { get; final set; } ]
diff --git a/ext/reflection/tests/abstract_property_indicated.phpt b/ext/reflection/tests/abstract_property_indicated.phpt
index a70d88b7ece29..23866be890d5b 100644
--- a/ext/reflection/tests/abstract_property_indicated.phpt
+++ b/ext/reflection/tests/abstract_property_indicated.phpt
@@ -31,12 +31,12 @@ Class [ <user> <iterateable> abstract class Demo ] {
   }
 
   - Properties [2] {
-    Property [ abstract public $a ]
+    Property [ abstract public virtual $a { get; } ]
     Property [ public $b = NULL ]
   }
 
   - Methods [0] {
   }
 }
-Property [ abstract public $a ]
+Property [ abstract public virtual $a { get; } ]
 Property [ public $b = NULL ]