fix: avoid internal error on higher-order expectations over magic properties - #4
Merged
MrPunyapal merged 1 commit intoAug 5, 2026
Conversation
…perties `HigherOrderExpectationTypeExtension::resolvePropertyType()` only bailed out when `hasProperty()` returned `no`. `Type::hasProperty()` is trinary, and `ObjectType::hasProperty()` returns `maybe` for classes that allow dynamic properties — which includes every class declaring `__get()`, `__set()` or `__isset()`. On `maybe` the guard let execution through and `ClassReflection::getProperty()` threw `MissingPropertyFromReflectionException`, which nothing caught, so PHPStan aborted the whole file with an internal error instead of reporting a regular, baselineable error. `maybe` cannot be used to decide on its own: it is also returned for types whose property is perfectly resolvable, such as `Post|null`. Bailing out on anything that is not `yes` would break those cases. The reflection lookup is therefore the only reliable check, so it is guarded with a `try`/`catch`, the same way PHPStan's own `AccessPropertiesCheck` and `ObjectShapeType` handle it. The unresolvable case falls back to `MixedType` rather than `null` so that the higher-order chain keeps its shape. Returning `null` degrades the expression to `mixed`, which turns a single internal error into a series of `method.nonObject` and `property.nonObject` errors for every subsequent link in the chain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TvRLUyRV47CJr9NfpxWg2M
MrPunyapal
approved these changes
Aug 5, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Any higher-order expectation on an object with
__get()crashes PHPStan with an internal error instead of producing a regular, reportable, baselineable error:Because it is an internal error, the whole file is aborted — only one error is reported per file, and it cannot be baselined.
This affects every class that declares
__get(),__set()or__isset(): Eloquent models,Illuminate\Http\Request, DTOs with magic properties, and so on.Reproduction
A class without any of the magic property methods does not crash.
Cause
HigherOrderExpectationTypeExtension::resolvePropertyType()only bails out whenhasProperty()returnsno:Type::hasProperty()is trinary.ObjectType::hasProperty()returnsmaybewhenClassReflection::allowsDynamicProperties()is true, which is the case for every class declaring__get(),__set()or__isset(). Onmaybethe guard lets execution through, andClassReflection::getProperty()throwsMissingPropertyFromReflectionException, which nothing catches.Fix
maybecannot be used to decide on its own — it is also returned for types whose property is perfectly resolvable, such asPost|null(already covered by the existing tests attests/Type/data/higher-order-expectations.php). Bailing out on anything that is notyeswould break those cases, so the reflection lookup itself is the only reliable check:This mirrors how PHPStan itself handles the same situation in
Rules\Properties\AccessPropertiesCheckandType\ObjectShapeType.The unresolvable case falls back to
MixedTyperather thannullon purpose. Returningnulldegrades the whole expression tomixed, which replaces the single internal error with a cascade ofmethod.nonObject/property.nonObjecterrors for every subsequent link in the chain:resolvePropertyType()behaviour5.xtry/catchreturningnullmethod.nonObject,property.nonObjecttry/catchreturningMixedTypeTests
Three cases added to
tests/Type/data/higher-order-expectations.php, plus aMagicPropertyObjectfixture:HigherOrderExpectation<Expectation<MagicPropertyObject>, mixed>array<string, mixed>|object|null) no longer crashesReverting only
src/makes the suite fail with the originalProperty $name was not found in reflection of class Tests\Type\Fixtures\MagicPropertyObject.composer testis green: rector, pint, phpstan (0 errors), pest (465 tests, 578 assertions).Notes
Type::hasProperty()/getProperty()are deprecated in favour of thehasInstanceProperty()/getInstanceProperty()variants. Switching them here changes behaviour for static properties accessed through an instance, so it is left out of this PR and can be done separately if wanted.