Skip to content

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

Merged
lisachenko merged 6 commits into
masterfrom
claude/php86-223-readonly-defaults
Sep 23, 2026
Merged

lisachenko merged 6 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

@lisachenko

Copy link
Copy Markdown
Member Author

@claude php8.6 beta 2 has been released, should we recheck this now?

@lisachenko

Copy link
Copy Markdown
Member Author

are there any updates here with newer PHP builds, @claude ?

Copy link
Copy Markdown
Member Author

On it now — apologies for the silence on the two pings: I was unsubscribed from this PR per the postponement and only saw them today (re-subscribed).

One correction to the premise: there is no php-8.6.0beta2 tag — php-src went straight from beta1 to RC1/RC2. I'm building PHP 8.6.0RC2 from source in the work environment right now (both RCs postdate the readonly-defaults merge of 2026-08-11, so RC2 should ship the feature). Once the build lands I'll probe public readonly int $x = 1;, bring the branch up to date with master, run the full test matrix — the parity test here self-activates on a supporting runtime — re-verify the final native semantics against the RC, and report results in this thread.


Generated by Claude Code

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn
PHP 8.6.0RC2 (the first pre-release line shipping the Readonly Property
Defaults RFC — the feature missed the beta1 tag; there is no beta2, the
line went beta1 -> RC1/RC2) accepts readonly properties with default
values, so the runtime feature probe now activates the native parity
test. Verification against the RC surfaced two runtime changes:

- PHP 8.6 deprecates ReflectionProperty::getDefaultValue() on a property
  without a default value; the parity comparison is limited to
  properties that have one, keeping the suite deprecation-clean.
- Native ReflectionClassConstant::__toString() renders boolean values as
  "true"/"false" since 8.6 (string cast before); the parsed rendering
  now follows the runtime. The guarding version check is a documented
  PHPStan ignore because the installed PHPStan caps its PHP version
  model at 8.5 and folds the branch to "always false".

Full matrix verified on PHP 8.6.0RC2: all 13 readonly-defaults tests
pass with the parity test active, full suite green with
--fail-on-deprecation, PHPStan level 10 clean, PHP 8.5 suite unchanged.

Refs #223

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn

Copy link
Copy Markdown
Member Author

Done — readonly property defaults verified against PHP 8.6.0RC2. The hold reason is resolved and this PR is ready for review.

I built php-8.6.0RC2 from source in the work environment (there is no beta2 tag — the line went beta1 → RC1 → RC2) and re-validated the whole matrix. public readonly string $x = 'def'; compiles and behaves as this PR's static reflection predicted: all 13 readonly-defaults tests pass with the native parity test now active (it self-activated via the runtime probe, exactly as designed — no changes to the parity expectations were needed).

Two 8.6 runtime changes surfaced during verification, handled in the new commit (3f6619a, on top of a fresh master merge):

  1. PHP 8.6 deprecates ReflectionProperty::getDefaultValue() on a property without a default ("use hasDefaultValue() to check"). The parity test now compares values only for properties that have one, keeping the suite deprecation-clean. Note for a possible follow-up: the parsed getDefaultValue() still silently returns null in that case — mirroring the native deprecation in the library is a design decision I've left to you.
  2. Native ReflectionClassConstant::__toString() renders booleans as true/false since 8.6 (string-cast 1/`` before). The parsed rendering now follows the runtime version. The PHP_VERSION_ID >= 80600 guard needed a documented `phpstan.neon` ignore because the installed PHPStan caps its PHP version model at 8.5.x and folds the branch to "always false" (its config rejects `phpVersion.max` ≥ 80600 outright).

Validation on the pushed head:

Check Result
ReadonlyPropertyDefaultsTest on PHP 8.6.0RC2 OK — 13 tests, 107 assertions, parity active, 0 deprecations
Full suite on PHP 8.6.0RC2 (--fail-on-deprecation) OK — 15054 tests, 30492 assertions, 0 failures
Full suite on PHP 8.5.9 OK — 13823 tests, 0 failures
PHPStan level 10 [OK] No errors

The CI 8.6 experimental job installs the latest 8.6 snapshot via setup-php, so the parity test should activate there as well. Merge is yours.


Generated by Claude Code

CI resolves a newer PHPStan than the local one; a release whose version
model includes 8.6 reports nothing on the guarded rendering branch, so
the two ignore entries must not fail as unmatched there.

Refs #223

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sy8BcM8ivUEpu8uVm7wADn
@lisachenko
lisachenko marked this pull request as ready for review September 23, 2026 22:10
@lisachenko
lisachenko merged commit e49a9d2 into master Sep 23, 2026
7 checks passed
@lisachenko
lisachenko deleted the claude/php86-223-readonly-defaults branch September 23, 2026 22:11
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