levelmax World 3 PHPStan to Max
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. B
L
Level 3.2 ~15 min Target: PHPStan level 10

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

Before
<?php

final class ImportSettings
{
    public function batchSize(): int
    {
        $settings = json_decode(file_get_contents($this->path), true);

        return (int) ($settings['batch_size'] ?? 100);
    }
}
After
<?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);
    }
}

Quick check

1. $data = json_decode($json, true); and then return $data['limit']; from a method declared : int. Which is the lowest level that reports it?
2. Level 9 is clean, and level 10 reports Binary operation "*" between mixed and 100. What's the likely cause?
3. A config value should be a positive int. Which version is the boundary check?

CHALLENGE

StoreConfig keeps the decoded JSON as array<string, mixed> and trusts it on every call, so a typo in the config file becomes a TypeError at checkout. At level 10, PHPStan won't accept any of it.

Rewrite src/StoreConfig.php so fromJson() checks every key once, and throws InvalidConfig naming the key when it's wrong. Keep the public methods: callers shouldn't notice anything but better types.

REFERENCE

The config file looks like this. basket and free_delivery_over are optional, with defaults of 50 items and £0.

{
  "currency": "GBP",
  "basket": {"max_items": 20},
  "free_delivery_over": 49.99,
  "payment_methods": ["Card", "PayPal"]
}
  • currency is three capital letters.
  • basket.max_items is a whole number of at least 1.
  • free_delivery_over is a number of pounds, not negative, which freeDeliveryOverPence() returns in pence.
  • payment_methods is a non-empty list of names, which paymentMethods() returns lower-cased.
Running on PHP 8.3…

Run the tests, run PHPStan, or submit for both. Ctrl/⌘ + Enter submits from the editor.