Introduction

PHPStan performs static analysis to catch type errors before runtime, but its type narrowing logic can produce false positives when it cannot track runtime type changes through method calls, array access, or dynamic property assignment. These false positives are frustrating because they cause CI failures on perfectly valid code, leading teams to either ignore PHPStan output entirely or lower the analysis level -- missing real bugs in the process. Understanding how PHPStan narrows types and how to provide additional type information is essential for maintaining a useful static analysis pipeline.

Symptoms

bash
Line 45: Cannot call method getName() on string|null.

But the code checks for null:

php
$user = $this->userRepository->find($id);
if ($user === null) {
    throw new NotFoundException();
}
echo $user->getName();  // PHPStan: Cannot call method on string|null

Or:

bash
Line 78: Access to an undefined property stdClass::$name.

When the property is dynamically set:

php
$config = json_decode(file_get_contents('config.json'));
echo $config->name;  // PHPStan: Undefined property stdClass::$name

Or with arrays:

bash
Line 102: Offset 'email' might not exist on array{ id: int, name: string }.

Common Causes

  • PHPStan cannot track type narrowing through method calls: It does not follow the logic across function boundaries
  • Dynamic properties not declared: json_decode returns stdClass with unknown properties
  • Array shape not inferred: PHPStan does not know the keys of arrays returned from database queries
  • Mixed type from untyped code: Calling into a library without type declarations returns mixed
  • Magic methods and properties: __get, __set, __call are not understood by PHPStan
  • Generic types not specified: Collection classes without PHPDoc type hints lose type information

Step-by-Step Fix

Step 1: Use assert() for runtime type narrowing

php
$user = $this->userRepository->find($id);
assert($user instanceof User);  // Tells PHPStan this is definitely a User
echo $user->getName();  // No error

Or use PHP 8's type-checking patterns:

php
$user = $this->userRepository->find($id);
if (!$user instanceof User) {
    throw new NotFoundException("User $id not found");
}
echo $user->getName();  // PHPStan knows $user is User here

Step 2: Use PHPDoc annotations for array shapes

```php /** * @return array{id: int, name: string, email: string} */ public function getUserData(int $id): array { return $this->db->fetchAssociative( 'SELECT id, name, email FROM users WHERE id = ?', [$id] ); }

$user = $this->getUserData(1); echo $user['email']; // PHPStan knows this key exists ```

Step 3: Use typed objects instead of stdClass

```php // WRONG - json_decode returns stdClass $config = json_decode(file_get_contents('config.json')); echo $config->name;

// CORRECT - decode to typed class class AppConfig { public function __construct( public string $name, public string $databaseUrl, public bool $debug, ) {}

public static function fromFile(string $path): self { $data = json_decode(file_get_contents($path), true); return new self( name: $data['name'], databaseUrl: $data['database_url'], debug: (bool) $data['debug'], ); } }

$config = AppConfig::fromFile('config.json'); echo $config->name; // PHPStan knows this is a string ```

Step 4: Configure PHPStan baseline for known false positives

```bash # Generate baseline for existing issues vendor/bin/phpstan analyse --generate-baseline phpstan-baseline.neon

# Then run normally - baseline issues are ignored vendor/bin/phpstan analyse src/ ```

Review and fix items in the baseline over time rather than ignoring them forever.

Prevention

  • Use PHP 8.0+ union types and named arguments for clearer type contracts
  • Add PHPDoc @param and @return annotations to all methods
  • Use assert() for type narrowing when the assertion is guaranteed by business logic
  • Use value objects instead of arrays and stdClass for structured data
  • Configure PHPStan at level 8 (maximum) and fix errors incrementally
  • Use phpstan/phpstan-strict-rules for additional type safety checks
  • Run PHPStan in CI on every PR, not just on the main branch