Skip to content

[PHP 8.6] ReflectionProperty::isReadable()/isWritable() emulation - #230

Merged
lisachenko merged 2 commits into
masterfrom
claude/php86-222-is-readable-writable
Aug 25, 2026
Merged

[PHP 8.6] ReflectionProperty::isReadable()/isWritable() emulation#230
lisachenko merged 2 commits into
masterfrom
claude/php86-222-is-readable-writable

Conversation

@lisachenko

Copy link
Copy Markdown
Member

Refs #222

Implements static (AST-based) emulation of the PHP 8.6 scope-aware property accessibility API in Go\ParserReflection\ReflectionProperty.

Deviation from the issue text (verified against the shipped 8.6.0beta1)

The actually shipped API differs from the signatures quoted in #222:

  • The method is spelled isWritable, not isWriteable.
  • $scope is required (nullable, but has no default): isReadable(?string $scope, ?object $object = null): bool — a zero-argument call raises ArgumentCountError natively.
  • Both methods accept the optional ?object $object, not only the write check.

All semantics below were derived empirically from php 8.6.0beta1, not from the RFC/issue text.

Implemented semantics

Scope check (null = global scope, class name = class scope):

  • public members: accessible from any scope — but a non-null scope class is still always resolved first, and an unknown scope raises Error: Class "..." not found, exactly like native.
  • protected: accessible when the scope is the declaring class, a descendant of it, or an ancestor of it (same rule as zend_check_protected); unrelated/sibling classes are denied.
  • private: accessible only from the exact declaring class (shadowed subclass privates are handled via the real declaring class).
  • Scope names are normalized like native: leading backslash stripped, comparison case-insensitive.

Write visibility takes asymmetric visibility into account: explicit private(set)/protected(set)/public(set), the implicit protected(set) of readonly properties (including promoted readonly and readonly classes), and explicit public(set) readonly.

Hooks: a virtual property without a get hook is never readable ({ set; }), without a set hook never writable ({ get; }); backed hooked properties behave like plain ones; abstract/interface hook declarations ({ get; }, { set; }, { get; set; }) count as capabilities.

$object handling (native parity):

  • objects are rejected for static properties (ReflectionException: null is expected as object argument for static properties) and for instances not matching the declaring class (ReflectionException: Given object is not an instance of the class this property was declared in) — both checks fire before scope resolution, in that order;
  • a readonly property is writable on an object only while uninitialized on it;
  • a property without a get hook is readable on an object only when initialized on it (a get hook short-circuits the initialization check, matching native);
  • only this initialization state uses the existing native-reflection fallback (InitializationTrait); everything else stays purely static.

Deliberate divergences

  • No autoloading of the scope class: native reflection resolves $scope through the autoloader. Here, an already-loaded class/interface/trait is used directly (no autoload), otherwise the scope is resolved statically via parser reflection; only if both fail is the native Error: Class "..." not found raised. This preserves the library's contract of not loading reflected code.
  • Object-validation errors are thrown as Go\ParserReflection\ReflectionException (a subclass of \ReflectionException) with the exact native messages.

Drive-by fix

The new stub matrix uncovered that isVirtual() treated a property with a single short-form get hook referencing its own backing store (get => $this->prop + 1;) as virtual, while native reflection reports it as backed. Short hook expressions are now scanned for backing-store access like block-form bodies.

Tests

  • New stub tests/Stub/FileWithPropertyAccessibility86.php (PHP 8.4/8.5 syntax only, parses and loads on all supported runtimes): full matrix of public/protected/private × plain/readonly (incl. public(set) readonly, promoted readonly, readonly class) × hooks (virtual get-only/set-only/both, backed get/set/both, protected hooked, asymmetric hooked) × static × promoted × inherited/shadowed × interface/abstract properties × enum name/value.
  • tests/PropertyAccessibilityTest.php:
    • hard-coded expectation matrix (generated from native 8.6.0beta1) asserted on every runtime over the pure AST path, verified to not load the stub classes;
    • object-dependent and error-ordering scenarios asserted directly (run on 8.5 and 8.6);
    • exhaustive native parity test (guarded PHP_VERSION_ID >= 80600): every property × 11 scopes (incl. backslash-prefixed, uppercased, unknown, empty) × 7 objects (uninitialized/initialized/subclass/foreign/enum case) × both methods — ~12,800 combinations, comparing values and exception family+message.

Local results

  • PHP 8.5.9: vendor/bin/phpunit — OK, 13750 tests, 15911 assertions (134 skipped, 2 incomplete — pre-existing coverage notes for other epic tickets)
  • PHP 8.6.0beta1: php8.6 vendor/bin/phpunit — OK, 13750 tests, 28694 assertions; the ReflectionProperty coverage gap now lists only getMangledName (separate ticket)
  • vendor/bin/phpstan analyse src --no-progress — level 10, no errors

🤖 Generated with Claude Code

https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn


Generated by Claude Code

claude added 2 commits August 25, 2026 10:03
Implements the PHP 8.6 scope-aware accessibility introspection API
statically from the AST:

- visibility checks for the given scope (null = global scope, class
  name = class scope), with protected members accessible from both
  descendant and ancestor scopes and private members only from the
  exact declaring class, matching zend_check_protected semantics
- asymmetric set-visibility (private(set)/protected(set)), including
  the implicit protected(set) of readonly properties and readonly
  classes, and explicit public(set) readonly
- hook capabilities: virtual properties without a get hook are never
  readable, without a set hook never writable; abstract/interface
  hook declarations count as capabilities
- scope class names are normalized (leading backslash, case) and
  resolved without autoloading: already loaded classes are used
  directly, otherwise the scope is resolved via parser reflection;
  an unresolvable scope raises Error 'Class "..." not found' like
  the native implementation
- object validation mirrors native reflection: objects are rejected
  for static properties and for instances of foreign classes before
  the scope is resolved
- only the initialization state of a concrete $object falls back to
  the native reflection: readonly properties are writable only while
  uninitialized, and properties without a get hook are readable only
  when initialized

Also fixes isVirtual() to detect backing store usage inside short-form
get hooks (e.g. `get => $this->prop + 1;`), which the new stub matrix
uncovered.

The stub matrix and its hard-coded expectations were generated from the
native PHP 8.6.0beta1 implementation; an exhaustive parity test compares
every property x scope x object combination against native reflection on
PHP >= 8.6.

Refs #222

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn
@lisachenko
lisachenko marked this pull request as ready for review August 25, 2026 14:06
@lisachenko
lisachenko merged commit 99ebe66 into master Aug 25, 2026
7 checks passed
@lisachenko
lisachenko deleted the claude/php86-222-is-readable-writable branch August 25, 2026 14:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants