Skip to content

Instantly share code, notes, and snippets.

@genesiscz
Last active March 3, 2026 22:41
Show Gist options
  • Select an option

  • Save genesiscz/84e132e2fa6cf8fcc00a952da9de22ab to your computer and use it in GitHub Desktop.

Select an option

Save genesiscz/84e132e2fa6cf8fcc00a952da9de22ab to your computer and use it in GitHub Desktop.
PHPStan bug: @phpstan-assert narrowing broken by method chaining (sub-expression) — see #13902, #5473
<?php
declare(strict_types=1);
use function PHPStan\dumpType;
/**
* Minimal reproduction: @phpstan-assert narrowing broken by method chaining
*
* @see https://github.com/phpstan/phpstan/issues/13902
* @see https://github.com/phpstan/phpstan/issues/5473
*/
class AssertResult
{
private function __construct(public readonly bool $ok) {}
public static function pass(): self { return new self(ok: true); }
public static function fail(): self { return new self(ok: false); }
/**
* Throw the given exception if the assertion failed.
*
* @throws \Throwable
*/
public function throws(\Throwable $exception): void
{
if (! $this->ok) {
throw $exception;
}
}
}
final class Assert
{
/**
* @phpstan-assert !null $value
*/
public static function notNull(mixed $value): AssertResult
{
if ($value !== null) {
return AssertResult::pass();
}
return AssertResult::fail();
}
/**
* @phpstan-assert !null $value
* @return void
*/
public static function notNullVoid(mixed $value): void
{
if ($value === null) {
throw new \RuntimeException('Value is null');
}
}
}
// ─── Case 1: Standalone statement (void return) ───────────────────
// ✅ WORKS — PHPStan narrows $value to string
function testStandaloneVoid(?string $value): void
{
Assert::notNullVoid($value);
dumpType($value); // string
}
// ─── Case 2: Standalone statement (non-void return) ───────────────
// ✅ WORKS — PHPStan still narrows $value to string
function testStandaloneNonVoid(?string $value): void
{
Assert::notNull($value);
dumpType($value); // string
}
// ─── Case 3: Chained method call ──────────────────────────────────
// ❌ BROKEN — PHPStan does NOT narrow $value
function testChained(?string $value): void
{
Assert::notNull($value)->throws(new \RuntimeException('Value is null'));
dumpType($value); // string|null (should be string)
}
// ─── Case 4: Assigned to variable ─────────────────────────────────
// ✅ WORKS — PHPStan narrows $value via assignment RHS processing
function testAssigned(?string $value): void
{
$_ = Assert::notNull($value);
dumpType($value); // string
}
// ─── Case 5: Property access on nullable after chained assert ─────
// ❌ BROKEN — same root cause, property access on still-nullable
function testPropertyAccess(?object $cart): void
{
Assert::notNull($cart)->throws(new \RuntimeException('Cart not found'));
dumpType($cart); // object|null (should be object)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment