Do not remember constructor property initialization for classes with custom serialization - #6188
Do not remember constructor property initialization for classes with custom serialization#6188phpstan-bot wants to merge 2 commits into
Conversation
…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.
| * | ||
| * @return array<string, true> | ||
| */ | ||
| private function getUnsetPropertiesInCurrentClassLike(): array |
There was a problem hiding this comment.
we should leave unset support out for now. concentrate this PR on the serialization use-case
There was a problem hiding this comment.
Done — unset() support removed, PR now focused on custom serialization only.
What was removed:
MutatingScope::unsetInitializedProperty()- The
$propertyNamesToForgetparameter threaded throughrememberConstructorScope()/rememberConstructorExpressions() NodeScopeResolver::getUnsetPropertiesInCurrentClassLike()plus the$currentClassLikeStatements/$unsetPropertiesInCurrentClassLikefields and the save/restore around nested class-likes- The
unsetInitializedProperty()call in theUnset_branch tests/PHPStan/Rules/Variables/data/property-initialization-unset.phpand the threetestPropertyInitializationUnset()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 errorsmake cs-fix— no violations- Reverted
src/Analyser/{MutatingScope,NodeScopeResolver}.phpto the pre-PR state and confirmed all four remaining serialization tests fail for the right reason (Sleep,SerializeAndUnserialize,OnlyUnserialize,InheritsSleep,SleepFromTrait,OldSchoolSerializableall 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.
unset($this->prop)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>
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)andempty($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->priceInforeally is uninitialized after a wakeup, yet the?? nullguard was flagged.Changes
src/Analyser/MutatingScope.phprememberConstructorExpressions()takes a set of property names to forget and no longer remembersPropertyInitializationExprentries - nor the readonlyPropertyFetchvalue entries, which are unsound for the same reason - whenclassHasCustomSerialization()holds.classHasCustomSerialization(): the class (or an ancestor, or a used trait) declares one ofCUSTOM_SERIALIZATION_METHODS(__sleep,__serialize,__unserialize), or implementsSerializableand declaresunserialize().unsetInitializedProperty(), the counterpart ofassignInitializedProperty().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.Serializablewithserialize()/unserialize().??=in addition to??, plusisset()andempty()- all three operators sharePHPStan\Rules\IssetCheck.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/MissingReadOnlyPropertyAssignRuleandUninitializedPropertyRuledo 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, andPropertyInitializationExprwas unconditionally on it.IssetabilityDescriptorturns that entry intoIssetabilityLinkInfo::isInitializedThisProperty(), whichIssetCheckrenders as "... is not nullable nor uninitialized". Two things can invalidate the entry and neither was modelled: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 inNullCoalesceRuleTest::testBug15056().tests/PHPStan/Rules/Variables/data/property-initialization-custom-serialization.php-__sleep,__serialize+__unserialize,__unserializealone, inherited__sleep, trait__sleep,Serializable, promoted properties,??=and an anonymous class, each exercised through??,isset()andempty(); plusNoSerialization,OnlyWakeup,PromotedNoSerializationandCoalesceAssignNoSerializationcontrol classes that must still report. Wired intoNullCoalesceRuleTest,IssetRuleTestandEmptyRuleTest.tests/PHPStan/Analyser/nsrt/bug-15056.php- pins the inferred types: with__sleep()a readonly property assigned in the constructor isstring(not'foo') in other methods and$this->readonlyString ?? nullisstring|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 phpstanandmake cs-fixare green.Fixes phpstan/phpstan#15056