levelmax World 2 Honest Types
  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. B
L
Level 2.1 ~12 min Target: PHPStan level 6

array is a shrug

array tells PHP you've got an array. It tells the next developer nothing. A list of SKUs, best seller first? Stock levels keyed by SKU? A database row? They all have the same type, so every caller reads the method body to find out.

World 2 is about types that say what you mean. It starts here, because from level 6 PHPStan stops accepting the shrug:

Method BestSellers::available() return type has no value type specified in iterable type array.

Two kinds of collection

Most arrays in application code are one of two things:

You have Type For example
An ordered sequence list<T> list<string>: SKUs, best seller first
A lookup by key array<K, V> array<string, int>: quantity by SKU

list<T> is the stronger promise: integer keys from 0, in order, with no gaps. That's what json_encode() turns into a JSON array, and what $skus[0] and [...$a, ...$b] quietly rely on. array<int, T> has integer keys too, but it doesn't promise they start at zero or have no gaps.

PHP has no syntax for any of this, so the native type stays array and the docblock says what's inside:

/** @var array<string, int> price in pence by SKU */
private array $prices = [];

/**
 * @param list<string> $skus
 * @return list<int>
 */
public function pricesFor(array $skus): array

The type is a promise, so keep it

Writing list<string> is easy. Keeping it is where the bugs are, because some of the most common array functions keep the original keys:

array_filter(['MUG-01', 'MUG-02', 'TEA-01'], $inStock);
// [1 => 'MUG-02', 2 => 'TEA-01']

PHPStan can catch that lie, but not yet. From level 7, returning array_filter()'s result from a method that promises list<string> is an error. Level 6 asks you to write the types. Level 7, the next level up, checks you meant them.

Power-up

Before
<?php

final class RecentlyViewed
{
    private array $skus = [];

    public function forget(string $sku): array
    {
        // A list goes in. A list with a gap in it comes out.
        $this->skus = array_filter($this->skus, fn ($s) => $s !== $sku);

        return $this->skus;
    }
}
After
<?php

final class RecentlyViewed
{
    /** @var list<string> SKUs, most recent first */
    private array $skus = [];

    /** @return list<string> */
    public function forget(string $sku): array
    {
        $this->skus = array_values(array_filter($this->skus, fn (string $s): bool => $s !== $sku));

        return $this->skus;
    }
}

Quick check

1. A method returns SKUs in best-seller order. Which return type says that?
2. What does array_filter(['a', '', 'b']) return?
3. At level 6, PHPStan reports @return array. What is it asking for?

CHALLENGE

BestSellers says array everywhere, so nobody can tell a list of SKUs from a stock lookup. It also has a bug: the homepage API sends {"skus":{"1":"MUG-02","3":"TEA-01"}}, and the frontend expects a JSON array.

Give every array in src/BestSellers.php a type that says what's inside it, and make available() return a real list, so the tests pass and PHPStan is clean at level 6.

Running on PHP 8.3…

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