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
<?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]); } }
<?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'), ]; } }