Skip to content

[PHP 8.6] Readonly property defaults — static behavior + upstream status - #228

Draft
lisachenko wants to merge 3 commits into
masterfrom
claude/php86-223-readonly-defaults
Draft

[PHP 8.6] Readonly property defaults — static behavior + upstream status#228
lisachenko wants to merge 3 commits into
masterfrom
claude/php86-223-readonly-defaults

Conversation

@lisachenko

Copy link
Copy Markdown
Member

Refs #223

Upstream status first: the feature is NOT in php-8.6.0beta1

Before writing anything, the assumption behind #223 was verified against a real 8.6 binary:

$ php8.6 -r 'class A { public readonly string $x = "d"; }'
Fatal error: Readonly property A::$x cannot have default value

(PHP 8.6.0beta1, built from the php-8.6.0beta1 tag.)

What the sources say:

Fact Source
RFC Readonly Property Defaults is accepted (24 yes / 0 no / 5 abstain, voting 2026-07-24 → 2026-08-06), status Implemented, target PHP 8.6 https://wiki.php.net/rfc/readonly_property_defaults
Implementation [RFC] Allow Readonly Property Defaults (GH-22588), commit a2640ae, merged into master on 2026-08-11 php/php-src#22588
UPGRADING on master (still the 8.6 line — no PHP-8.6 branch exists yet) lists it under PHP 8.6 → New Features → Core: “Readonly properties may now declare default values.” https://github.com/php/php-src/blob/master/UPGRADING
The php-8.6.0beta1 tag was cut 2026-08-11 and does not contain the commit (no beta2 tag yet) https://github.com/php/php-src/tags

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 a readonly class, and via traits/inheritance.
  • ReflectionClass::getDefaultProperties() includes readonly defaults and correctly omits readonly properties without one.
  • Promoted readonly parameters keep native semantics: the property reports no default (see bugs.php.net/81386), the parameter reports it.

One real defect surfaced while probing that surface:

$property->getDefaultValueExpression();
// Error: Typed property Go\ParserReflection\ReflectionProperty::$defaultValueConstExpr
//        must not be accessed before initialization

$defaultValueConstExpr / $defaultValueConstantName were 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 in ReflectionParameter (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 as FileWithFinalPromoted85.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, so eval() in a try/catch cannot probe it — it kills the process. The probe therefore runs PHP_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 a TODO to 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 — NodeExpressionResolver falls back to a parsed ReflectionClass, whose getConstants() does not expose enum cases, so the value comes back as false instead of the case instance. This is a pre-existing, general gap (reproducible with a plain non-readonly property, unrelated to readonly), and producing a real enum instance is impossible without loading the enum. The test therefore asserts only hasDefaultValue() and the rendered expression, and does not enshrine the false. Worth a separate issue if parity there is wanted.

Also noted while probing, left untouched as out of scope: ReflectionProperty::isInitialized() with no argument returns hasDefaultValue(), whereas native throws TypeError: 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

Check Result
vendor/bin/phpunit (PHP 8.5.9) OK — 13756 tests, 15481 assertions, 134 skipped, 2 incomplete
php8.6 vendor/bin/phpunit (PHP 8.6.0beta1) OK — 13756 tests, 15481 assertions, 134 skipped, 3 incomplete
vendor/bin/phpstan analyse src --no-progress (level 10) [OK] No errors

Baseline on master was 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

claude added 3 commits August 25, 2026 09:56
…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
@lisachenko

Copy link
Copy Markdown
Member Author

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

Copy link
Copy Markdown
Member Author

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 public readonly int $x = 1;, re-validate this PR's test matrix against it, and report back in this thread either way. I've also unsubscribed from this PR's activity until then, as requested.

For reference, the branch is already up to date with master and fully CI-green; the parity test in ReadonlyPropertyDefaultsTest self-activates via a runtime feature probe, so it needs no code change once a supporting build lands — only re-verification of the final native semantics.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Development

Successfully merging this pull request may close these issues.

2 participants