Skip to content

fix: avoid internal error on higher-order expectations over magic properties - #4

Merged
MrPunyapal merged 1 commit into
pestphp:5.xfrom
thimarsola:fix/higher-order-expectation-magic-property-crash
Aug 5, 2026
Merged

fix: avoid internal error on higher-order expectations over magic properties#4
MrPunyapal merged 1 commit into
pestphp:5.xfrom
thimarsola:fix/higher-order-expectation-magic-property-crash

Conversation

@thimarsola

@thimarsola thimarsola commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Problem

Any higher-order expectation on an object with __get() crashes PHPStan with an internal error instead of producing a regular, reportable, baselineable error:

Internal error: Property $name was not found in reflection of class Repro.

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

<?php

declare(strict_types=1);

class Repro
{
    public function __get(string $name): mixed
    {
        return null;
    }
}

test('repro', function (): void {
    expect(new Repro)->name->toBe('a');
});
includes:
    - vendor/pestphp/pest-plugin-phpstan/extension.neon

parameters:
    level: 5
    paths:
        - .

A class without any of the magic property methods does not crash.

Cause

HigherOrderExpectationTypeExtension::resolvePropertyType() only bails out when hasProperty() returns no:

if ($objectType->hasProperty($propertyName)->no()) {
    return null;
}

return $objectType->getProperty($propertyName, $scope)->getReadableType();

Type::hasProperty() is trinary. ObjectType::hasProperty() returns maybe when ClassReflection::allowsDynamicProperties() is true, which is the case for every class declaring __get(), __set() or __isset(). On maybe the guard lets execution through, and ClassReflection::getProperty() throws MissingPropertyFromReflectionException, which nothing catches.

Fix

maybe cannot be used to decide on its own — it is also returned for types whose property is perfectly resolvable, such as Post|null (already covered by the existing tests at tests/Type/data/higher-order-expectations.php). Bailing out on anything that is not yes would break those cases, so the reflection lookup itself is the only reliable check:

try {
    return $objectType->getProperty($propertyName, $scope)->getReadableType();
} catch (MissingPropertyFromReflectionException) {
    return new MixedType;
}

This mirrors how PHPStan itself handles the same situation in Rules\Properties\AccessPropertiesCheck and Type\ObjectShapeType.

The unresolvable case falls back to MixedType rather than null on purpose. Returning null degrades the whole expression to mixed, which replaces the single internal error with a cascade of method.nonObject / property.nonObject errors for every subsequent link in the chain:

expect($model)->name->toBe('Nuno')->email->toBe('x@y.z');
resolvePropertyType() behaviour analysis output (level max)
current 5.x internal error, file aborted
try/catch returning null 4 errors: method.nonObject, property.nonObject
try/catch returning MixedType 0 errors, chain stays typed

Tests

Three cases added to tests/Type/data/higher-order-expectations.php, plus a MagicPropertyObject fixture:

  • magic property resolves to HigherOrderExpectation<Expectation<MagicPropertyObject>, mixed>
  • magic property followed by a matcher keeps the chain shape
  • a union without a class type (array<string, mixed>|object|null) no longer crashes

Reverting only src/ makes the suite fail with the original Property $name was not found in reflection of class Tests\Type\Fixtures\MagicPropertyObject.

composer test is green: rector, pint, phpstan (0 errors), pest (465 tests, 578 assertions).

Notes

Type::hasProperty() / getProperty() are deprecated in favour of the hasInstanceProperty() / 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.

…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
@nunomaduro
nunomaduro requested a review from MrPunyapal August 5, 2026 15:16
@MrPunyapal
MrPunyapal merged commit 0b7e3ce into pestphp:5.x Aug 5, 2026
10 checks passed
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.

2 participants