Skip to content

Do not remember constructor property initialization for classes with custom serialization - #6188

Open
phpstan-bot wants to merge 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-cdtvwmy
Open

Do not remember constructor property initialization for classes with custom serialization#6188
phpstan-bot wants to merge 2 commits into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-cdtvwmy

Conversation

@phpstan-bot

@phpstan-bot phpstan-bot commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

PHPStan carries the property-initialization state established by a constructor over into all the other methods of the class, so $this->prop ?? null, isset($this->prop) and empty($this->prop) get reported as redundant there. That assumption does not hold for a class that customizes serialization: unserialize() rebuilds the object without ever running the constructor, and __sleep() / __serialize() / __unserialize() decide which properties make the round trip. The reported class returned [] from __sleep(), so $this->priceInfo really is uninitialized after a wakeup, yet the ?? null guard was flagged.

Changes

  • src/Analyser/MutatingScope.php
    • rememberConstructorExpressions() takes a set of property names to forget and no longer remembers PropertyInitializationExpr entries - nor the readonly PropertyFetch value entries, which are unsound for the same reason - when classHasCustomSerialization() holds.
    • New classHasCustomSerialization(): the class (or an ancestor, or a used trait) declares one of CUSTOM_SERIALIZATION_METHODS (__sleep, __serialize, __unserialize), or implements Serializable and declares unserialize().
    • New unsetInitializedProperty(), the counterpart of assignInitializedProperty().
    • rememberConstructorScope() accepts the property names to forget.

Analogous cases probed and covered by the same fix (each was reproduced as a false positive first):

  • __serialize() + __unserialize(), and __unserialize() alone (PHP 7.4+ calls it even for default-serialized payloads).
  • __sleep() inherited from a parent class, and __sleep() provided by a trait.
  • Serializable with serialize()/unserialize().
  • Promoted constructor properties.
  • ??= in addition to ??, plus isset() and empty() - all three operators share PHPStan\Rules\IssetCheck.
  • Anonymous classes.

Probed and deliberately left alone:

  • __wakeup() on its own does not count: with default serialization every initialized property is written out and restored, so the constructor's facts still hold. Covered by an expected-error control class in the test data.
  • ReadOnlyPropertyAssignRule / MissingReadOnlyPropertyAssignRule and UninitializedPropertyRule do not read the carried-over scope (they work off the constructor's own scope and the methods it calls), so they need no change.

Root cause

MutatingScope::rememberConstructorScope() keeps a whitelist of expressions whose truth survives past the constructor, and PropertyInitializationExpr was unconditionally on it. IssetabilityDescriptor turns that entry into IssetabilityLinkInfo::isInitializedThisProperty(), which IssetCheck renders as "... is not nullable nor uninitialized". Two things can invalidate the entry and neither was modelled:

  1. Custom serialization - unserialize() skips the constructor, and the author's __sleep()/__serialize()/__unserialize() decides which properties come back. The fix drops the whole property part of the remembered state for such classes. The readonly-value entries in the same whitelist are unsound in exactly the same way (__unserialize() may assign a different value to a still-uninitialized readonly property), so they are dropped too.

Test

  • tests/PHPStan/Rules/Variables/data/bug-15056.php - the reporter's playground sample verbatim, asserted to produce no errors in NullCoalesceRuleTest::testBug15056().
  • tests/PHPStan/Rules/Variables/data/property-initialization-custom-serialization.php - __sleep, __serialize+__unserialize, __unserialize alone, inherited __sleep, trait __sleep, Serializable, promoted properties, ??= and an anonymous class, each exercised through ??, isset() and empty(); plus NoSerialization, OnlyWakeup, PromotedNoSerialization and CoalesceAssignNoSerialization control classes that must still report. Wired into NullCoalesceRuleTest, IssetRuleTest and EmptyRuleTest.
  • tests/PHPStan/Analyser/nsrt/bug-15056.php - pins the inferred types: with __sleep() a readonly property assigned in the constructor is string (not 'foo') in other methods and $this->readonlyString ?? null is string|null; without __sleep() the constructor's value is still remembered.

All seven new rule-test cases were verified to fail without the source change (git stash push src/), each for the expected reason. make tests, make phpstan and make cs-fix are green.

Fixes phpstan/phpstan#15056

…custom serialization or `unset($this->prop)`

* `MutatingScope::rememberConstructorExpressions()` now drops the
  `PropertyInitializationExpr` entries (and the readonly `PropertyFetch` value
  entries) it used to carry from the constructor scope into the other methods
  when the class declares `__sleep()`, `__serialize()`, `__unserialize()`, or
  implements `Serializable` with an `unserialize()` method - `unserialize()`
  rebuilds such an object without running the constructor and the author decides
  which properties survive the round trip.
* `MutatingScope::unsetInitializedProperty()` + the `Unset_` branch of
  `NodeScopeResolver` remove the initialization fact when `unset($this->prop)`
  runs, so `$this->prop ?? ...` right after an `unset()` is no longer reported
  as redundant (and an `unset()` at the end of the constructor now correctly
  leaves the property uninitialized).
* `NodeScopeResolver::getUnsetPropertiesInCurrentClassLike()` collects the
  properties `unset()` anywhere in the class body and passes them to
  `rememberConstructorScope()`, covering the case where the `unset()` lives in a
  different method than the `??`/`isset()`/`empty()` check.
* The same code path backs `nullCoalesce.initializedProperty`,
  `isset.initializedProperty` and `empty.initializedProperty`, and the fix also
  covers promoted constructor properties, `??=`, anonymous classes, inherited
  and trait-provided `__sleep()`. `__wakeup()` alone still keeps the assumption,
  since default serialization round-trips every initialized property.
Comment thread src/Analyser/NodeScopeResolver.php Outdated
*
* @return array<string, true>
*/
private function getUnsetPropertiesInCurrentClassLike(): array

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should leave unset support out for now. concentrate this PR on the serialization use-case

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — unset() support removed, PR now focused on custom serialization only.

What was removed:

  • MutatingScope::unsetInitializedProperty()
  • The $propertyNamesToForget parameter threaded through rememberConstructorScope() / rememberConstructorExpressions()
  • NodeScopeResolver::getUnsetPropertiesInCurrentClassLike() plus the $currentClassLikeStatements / $unsetPropertiesInCurrentClassLike fields and the save/restore around nested class-likes
  • The unsetInitializedProperty() call in the Unset_ branch
  • tests/PHPStan/Rules/Variables/data/property-initialization-unset.php and the three testPropertyInitializationUnset() methods

What remains is the serialization fix alone: classHasCustomSerialization() (__sleep/__serialize/__unserialize, or Serializable + unserialize(), including inherited and trait-provided) gating whether PropertyInitializationExpr and readonly PropertyFetch entries survive rememberConstructorScope().

Verification:

  • make tests — 21223 tests, 96744 assertions, OK (97 skipped, pre-existing)
  • make phpstan — no errors
  • make cs-fix — no violations
  • Reverted src/Analyser/{MutatingScope,NodeScopeResolver}.php to the pre-PR state and confirmed all four remaining serialization tests fail for the right reason (Sleep, SerializeAndUnserialize, OnlyUnserialize, InheritsSleep, SleepFromTrait, OldSchoolSerializable all reported), then restored and confirmed they pass.

One thing left as-is: the base commit's message still reads "...custom serialization or unset($this->prop)". I didn't reword it, since amending would change the SHA the review comment is anchored to — the PR title is already accurate, so a squash-merge picks that up.

@staabm staabm changed the title Do not remember constructor property initialization for classes with custom serialization or unset($this->prop) Do not remember constructor property initialization for classes with custom serialization Aug 6, 2026
Reviewer feedback: keep this PR focused on the serialization use-case.

Reverts `unsetInitializedProperty()`, the `$propertyNamesToForget`
parameter threaded through `rememberConstructorScope()` /
`rememberConstructorExpressions()`, and the class-body `unset()` scan in
NodeScopeResolver, along with the `property-initialization-unset.php` test
data and the three rule tests using it. `unset($this->prop)` can be
handled separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PHPStan assumes that a property is always initialized, even though this is not necessarily the case

2 participants