[PHP 8.6] Readonly property defaults — static behavior + upstream status - #228
[PHP 8.6] Readonly property defaults — static behavior + upstream status#228lisachenko wants to merge 3 commits into
Conversation
…e initialization `ReflectionProperty::getDefaultValueExpression()` and `ReflectionParameter::getDefaultValueExpression()` read backing fields that were only assigned when a default value node is present, so calling them on a typed property or parameter without a default aborted with "Typed property ... must not be accessed before initialization". This shows up directly on `readonly` properties that declare no default value, which is part of the PHP 8.6 readonly-defaults surface, but the problem is generic for any typed declaration without a default. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn
Adds a parse-only stub with the whole readonly-defaults matrix (scalar, null, constant expression, class constant, enum case, array, readonly class, trait, inheritance, promoted parameters) and tests that document how static reflection reports it today. The "Readonly Property Defaults" RFC is accepted for PHP 8.6, but its implementation landed in php-src after the php-8.6.0beta1 tag, so no released runtime compiles this syntax yet. The stub is therefore never loaded, and the native parity test stays skipped until a runtime accepts readonly defaults, detected by a sub-process probe because the rejection is an uncatchable compile-time fatal error. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn
|
Let's postpone here the implementation, until new version of 8.6 will be released. @claude you can unsubscribe from this PR, schedule server callback in 1 month to recheck if this fixed in PHP8.6 already or not, reply in thread |
|
Understood — postponing this until a PHP 8.6 build that actually ships readonly property defaults (the RFC implementation merged into php-src on 2026-08-11, right after the beta1 tag was cut, so beta2/RC should carry it). I've scheduled a recheck for 2026-09-25: it will probe whether a released 8.6 build accepts For reference, the branch is already up to date with Generated by Claude Code |
Refs #223
Upstream status first: the feature is NOT in
php-8.6.0beta1Before writing anything, the assumption behind #223 was verified against a real 8.6 binary:
(PHP 8.6.0beta1, built from the
php-8.6.0beta1tag.)What the sources say:
[RFC] Allow Readonly Property Defaults (GH-22588), commita2640ae, merged intomasteron 2026-08-11UPGRADINGonmaster(still the 8.6 line — noPHP-8.6branch exists yet) lists it under PHP 8.6 → New Features → Core: “Readonly properties may now declare default values.”php-8.6.0beta1tag was cut 2026-08-11 and does not contain the commit (nobeta2tag yet)So the feature is genuinely coming in 8.6, it simply missed the beta1 snapshot by hours and should appear in the next beta/RC. Nothing here is blocked by that, but the final native semantics can only be re-verified once a build ships it — please decide whether to hold #223 until then.
Consequence for this PR: native parity assertions are impossible today, because no available runtime can even compile such a class. Everything asserted here is the static side, which PHP-Parser handles regardless of runtime support.
What was found
Static reflection already handles readonly defaults correctly — the readonly modifier turned out to be orthogonal to default-value resolution:
hasDefaultValue()/getDefaultValue()work for readonly properties with scalar,null, array, constant-expression and class-constant defaults, at every visibility, in areadonly class, and via traits/inheritance.ReflectionClass::getDefaultProperties()includes readonly defaults and correctly omits readonly properties without one.One real defect surfaced while probing that surface:
$defaultValueConstExpr/$defaultValueConstantNamewere assigned only inside the “has a default value” branch of the constructor, so the getter fataled for any typed declaration without a default — e.g.public readonly string $noDefault;. The same latent fatal existed inReflectionParameter(plus an uninitialized$isDefaultValueConstExpr).Changes
src/ReflectionProperty.php,src/ReflectionParameter.php— initialize the default-value expression backing fields (= null/= false). Three-line, behavior-preserving fix; nothing else in the readonly path needed changing.tests/Stub/FileWithReadonlyDefaults86.php— new parse-only stub covering the full matrix from [PHP8.6] Readonly property default values #223: scalar /null/ const-expression / class-constant / enum-case / array defaults, all visibilities,private(set) readonly, readonly property without default,readonly class, trait + inheritance, promoted readonly parameters. It is never included or autoloaded (PSR-4 cannot resolve it, same convention asFileWithFinalPromoted85.php).tests/ReadonlyPropertyDefaultsTest.php— 13 tests documenting the static behavior, valid on 8.5 and 8.6 today.About the guarded parity test
testNativeParityForReadonlyDefaults()compares parsed vs native for the whole stub, but is gated on a runtime feature probe. The rejection is an uncatchable compile-time fatal error, soeval()in atry/catchcannot probe it — it kills the process. The probe therefore runsPHP_BINARY -n -r 'class … { public readonly int $probe = 1; }'in a short-lived child process and checks the exit code, caching the result. It skips everywhere today, and will activate by itself on the first runtime that accepts the syntax. Marked with aTODOto re-verify expectations against final native semantics at that point.Known limitation, deliberately not asserted as correct
An enum case used as a default (
public readonly Suit $suit = Suit::Spades;) cannot be materialized statically while the enum is not loaded —NodeExpressionResolverfalls back to a parsedReflectionClass, whosegetConstants()does not expose enum cases, so the value comes back asfalseinstead of the case instance. This is a pre-existing, general gap (reproducible with a plain non-readonly property, unrelated toreadonly), and producing a real enum instance is impossible without loading the enum. The test therefore asserts onlyhasDefaultValue()and the rendered expression, and does not enshrine thefalse. Worth a separate issue if parity there is wanted.Also noted while probing, left untouched as out of scope:
ReflectionProperty::isInitialized()with no argument returnshasDefaultValue(), whereas native throwsTypeError: Argument #1 ($object) must be provided for instance properties; and__toString()renders const-expression defaults as the source expression (self::PREFIX . 'books') where native renders the evaluated value ('migration_books').Local results
vendor/bin/phpunit(PHP 8.5.9)php8.6 vendor/bin/phpunit(PHP 8.6.0beta1)vendor/bin/phpstan analyse src --no-progress(level 10)[OK] No errorsBaseline on
masterwas 13743 tests; the 13 new tests account for the difference, 1 of them skipped (the parity test) on both runtimes.🤖 Generated with Claude Code
https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn
Generated by Claude Code