mixed, the final enemy
mixed means "PHPStan has no idea". It arrives wherever your code meets the outside world:
| Source | Why it's mixed |
|---|---|
json_decode(), unserialize() |
The payload could be anything |
$_GET, $_POST, $request->input() |
The user could send anything |
getenv(), config('…'), include 'config.php' |
Somebody edited a file |
array<string, mixed> |
Somebody silenced level 6 |
| A parameter or closure with no type | Nobody said |
Levels 0 to 8 let mixed through: you can index into it, add it, and return it as an int, and PHPStan assumes you know best. The last two levels close that door.
| Level | What it reports |
|---|---|
| 9 | Using explicit mixed (a json_decode() result, an array<string, mixed> value) as anything other than mixed |
| 10 | The same for implicit mixed, where a type is simply missing, such as fn ($pounds) => $pounds * 100 |
Level 10 is max today. It's also where the last place for a wrong type to hide goes away.
Don't pass it on, check it once
The tempting fix is to cast, (int) $data['max_items'], or to add @var. Both turn "I don't know" into "I'm sure" without finding out. (int) 'twenty' is 0, and a basket limit of zero empties every basket.
Instead, check the value where it enters, turn it into a real type, and keep only the real type:
public static function fromJson(string $json): self { $data = json_decode($json, true, flags: JSON_THROW_ON_ERROR); if (!is_array($data)) { throw InvalidConfig::key('(root)', 'an object'); } $currency = $data['currency'] ?? null; if (!is_string($currency) || preg_match('/^[A-Z]{3}$/', $currency) !== 1) { throw InvalidConfig::key('currency', 'a three-letter currency code'); } // ... every other key the same way return new self($currency, ...); }
After that, the object holds a string, an int, and a list<string>. Nothing past the constructor ever sees mixed, and a bad config fails when the app boots, with a message that names the key, rather than at checkout with a TypeError.
Implicit mixed
Level 6 already asks for parameter and return types on methods. What it doesn't cover are closures and arrow functions:
$toPence = fn ($pounds) => (int) round($pounds * 100);
$pounds has no type, so it's implicitly mixed, and only level 10 objects to the multiplication. Give the closure a parameter type, or better, check the value before it gets there so a plain float reaches it.
Power-up
<?php final class ImportSettings { public function batchSize(): int { $settings = json_decode(file_get_contents($this->path), true); return (int) ($settings['batch_size'] ?? 100); } }
<?php final readonly class ImportSettings { private function __construct(public int $batchSize) {} public static function fromFile(string $path): self { $settings = json_decode((string) file_get_contents($path), true, flags: JSON_THROW_ON_ERROR); $batchSize = is_array($settings) ? $settings['batch_size'] ?? 100 : null; if (!is_int($batchSize) || $batchSize < 1) { throw new UnexpectedValueException("{$path}: batch_size must be a positive whole number"); } return new self($batchSize); } }