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

Array shapes: say what's in the box

You've written this method a hundred times. It returns array, and everyone who calls it opens the SQL to find out which keys come back, and whether id is an int or a string.

list<T> and array<K, V> from 2.1 describe collections, where every value has the same type. A row isn't like that. It's a fixed set of keys, each with its own type. That's an array shape:

/** @return array{reference: non-empty-string, totalPence: int, items: list<string>} */

PHPStan then knows exactly which keys exist and what's behind each one. Ask for $order['refrence'] and it tells you that key doesn't exist.

More shape syntax

Shape Means
array{note?: string} note might not be there. When it is, it's a string.
array{note: string|null} note is always there, and might be null.
array{int, int} A pair with keys 0 and 1, like [$productId, $quantity]
list<array{sku: string, quantity: int}> A list of shapes, such as the lines of an order

An optional key and a nullable value aren't the same thing. Read an optional key with $order['note'] ?? null or check it with array_key_exists(). A nullable value is always there, so you can read it directly.

Level 7 checks you built it

At level 6, returning a raw array<string, mixed> row from a method that promises a shape passes. The row might be that shape, and level 6 lets maybes through. From level 7 it's an error:

Method OrderRepository::summary() should return array{reference: non-empty-string, totalPence: int, items: list<string>} but returns array<string, mixed>.

That error is right. PDO hands you strings for most columns, JSON columns arrive as text, and SELECT * brings back columns you never meant to share. Somebody has to turn that into the shape, and the boundary is where it belongs: one method that reads the row, converts each value, and refuses anything that doesn't fit.

When a shape should be an object

Shapes are great for rows, payloads, and configuration: data that crosses a boundary and gets used nearby. Once a shape has more than four or five keys, passes through more than one layer, or needs behaviour of its own, it probably wants to be a readonly class. You'll build exactly that in the boss level.

Power-up

Before
<?php

final class OrderRepository
{
    /** @return array */
    public function summary(int $id): array
    {
        // Strings for numbers, a JSON column as text, and every column in the table.
        return $this->db->row('SELECT * FROM orders WHERE id = ?', [$id]);
    }
}
After
<?php

final class OrderRepository
{
    /** @return array{reference: non-empty-string, totalPence: int, items: list<string>} */
    public function summary(int $id): array
    {
        $row = $this->db->row('SELECT reference, total_pence, items FROM orders WHERE id = ?', [$id]);

        return [
            'reference' => Row::nonEmptyString($row, 'reference'),
            'totalPence' => Row::int($row, 'total_pence'),
            'items' => Row::stringList($row, 'items'),
        ];
    }
}

Quick check

1. What's the difference between array{note?: string} and array{note: string|null}?
2. find() promises array{id: int, email: non-empty-string} and returns the raw array<string, mixed> row. Why does that pass at level 6 and fail at level 7?
3. A shape has nine keys and is passed through three layers of the application. What should it probably be?

CHALLENGE

UserRepository::find() returns whatever the database sends: string IDs, roles as a JSON string, and every other column in the table. Callers have to read the SQL to know what they've got.

Give find() a shape that tells the truth, and build the array it promises using Row's helpers, so the tests pass and PHPStan is clean at level 7. It should return id, email and roles, and nothing else.

Running on PHP 8.3…

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