In PHPStan, what do the PHPDoc types list<T> and array{...} shapes express that PHP's native array type cannot?
answer
- native array says nothing inside
- list: keys 0, 1, 2 with no gaps
- array_filter breaks a list
- shape: per-key types, optional key?
- int<1, max> and non-empty-list
basics
~20 sNative array only says the value is an array. list<T> means keys 0, 1, 2 with no gaps and values of type T; array{id: int, name?: string} describes each key and its type, optional keys included.
solid answer
~40 sPHP's `array` declaration carries no key or value types, so PHPStan reads PHPDoc for them. `list<User>` means a list: sequential integer keys starting at 0 with no gaps, every value a `User`. It differs from `array<int, User>`, which allows any integer keys. PHPStan tracks list-ness through functions: `array_values()` makes a list, `array_map()` keeps one, `array_filter()` does not, so returning a filtered list where `list<T>` is declared is an error until you wrap it in `array_values()`. An **array shape** such as `array{id: int, email: string, nickname?: string}` types each key separately, with `?` for optional keys, and `array{int, int}` is a tuple. Integer ranges such as `int<1, max>` or `positive-int` can appear inside both. PHPStan 2.2 adds unsealed shapes, `array{id: int, ...}`, and with Bleeding Edge treats plain shapes as truly sealed, rejecting extra keys.
code
php · 16 lines<?php
declare(strict_types=1);
/**
* @param array{id: int, email: string, nickname?: string} $row
* @param list<mixed> $tags
* @return list<string>
*/
function labels(array $row, array $tags): array
{
$labels = array_filter($tags, 'is_string'); // keys may now have gaps
$labels[] = $row['email'];
return array_values($labels); // a list again
}go deeper
Know that list<T> means an array indexed 0, 1, 2 without gaps and that array{...} describes the type of each key.
Explain list versus array<int, T>, which functions create or break a list, optional keys and tuples in shapes.
Use shapes to type legacy array-passing code at boundaries, know the 2.2 sealed and unsealed semantics, and decide when a shape should become a class.
Set conventions for arrays versus value objects across the codebase, using PHPDoc types as a migration path rather than a permanent substitute.
## Why PHPDoc types exist at all PHP's native `array` type declaration tells the engine only that a value is an array. It says nothing about keys or values, and PHP has no native generics. **PHPStan reads PHPDoc tags** (`@param`, `@return`, `@var`) for richer types and checks the code against them. The native declaration stays as it is; the PHPDoc type must be compatible with it and more specific. ## list<T> ```php /** * @param list<User> $users * @return list<User> */ function activeUsers(array $users): array { return array_values(array_filter($users, fn (User $u) => $u->active)); } ``` - `list<User>` means **sequential integer keys starting at 0, no gaps**, every value a `User`. `non-empty-list<User>` also guarantees at least one element. - `array<int, User>` is weaker: keys are integers, but may be `5, 9, 12`. - PHPStan follows list-ness through the standard library. `array_values()` **creates** a list, `array_map()` over a list **keeps** it, and `array_filter()` **breaks** it, because filtering leaves gaps in the keys. Without `array_values()` above, the return would be reported as not matching `list<User>`. - PHPStan 2.0 made the `list` type enforced for everyone, as the upgrade guide lists among its changes. ## Array shapes An **array shape** types each key of a structured array: | Syntax | Meaning | |---|---| | `array{id: int, name: string}` | exactly these keys with these types | | `array{id: int, nickname?: string}` | `nickname` may be absent | | `array{int, int}` | a tuple: keys `0` and `1` | | `list{int, string}` | a list-shaped tuple, same as `array{0: int, 1: string}` | | `array{id: int, ...}` | unsealed (2.2): other keys allowed | | `array{id: int, ...<string, int>}` | unsealed with typed extra keys (2.2) | Shapes are how legacy code that passes associative arrays around, such as rows from `PDOStatement::fetch()` or decoded JSON, gets real type checking without introducing classes first. PHPStan then reports a missing key, a wrong value type, or a typo like `$row['emial']`. ## Sealed and unsealed shapes in 2.2 For years PHPStan was inconsistent about extra keys in a shape like `array{a: int}`: it accepted arrays with more keys but then reasoned as if there were none. PHPStan 2.2 introduced **unsealed** syntax with `...` for shapes that may carry more keys. With **Bleeding Edge** enabled, plain shapes become **truly sealed**: extra keys and general arrays are rejected, with a reason such as *Sealed array shape does not accept array with extra key 'year'*. The release post says this becomes the only behaviour in the next major version. ## Integer ranges and other refinements Refined scalar types combine naturally with lists and shapes: - `positive-int`, `non-negative-int`, `negative-int`, `non-zero-int`; - `int<0, 100>`, `int<min, 100>`, `int<1, max>` for explicit bounds; - `non-empty-array<T>` and `non-empty-list<T>`. For example, `array{page: int<1, max>, perPage: int<1, 100>}` documents and checks pagination input far better than `array`. ## PHPDoc types are not runtime checks A shape or list annotation is a **promise PHPStan checks inside your code**, not a validation of data coming in. Values that cross a boundary, such as the result of `json_decode($body, true)` (declared to return `mixed`), a row from the database or a request payload, arrive untyped. Annotating them directly with `@var array{id: int, email: string}` only asserts the shape; PHP will not reject a payload where `id` is a string. The robust pattern is to validate at the boundary (checks, a validation library, or constructing a typed object) and let PHPStan carry the proven type inward from there. Inside the code, the shape then documents and enforces the structure for every function that receives it. ## When to use which 1. **`list<T>`** for ordered collections where keys carry no meaning: query results, IDs to process. 2. **`array<K, V>`** for maps keyed by something meaningful: `array<string, Product>` keyed by SKU. 3. **Array shapes** for fixed-structure records, especially at the boundary of legacy code, often as a step before replacing the array with a small readonly class. 4. Once a shape is used in many places, a `@phpstan-type` alias or a real class keeps it in one definition. Interviewers usually probe the `array_filter()` trap and the list versus `array<int, T>` difference, because both show whether a candidate has actually had PHPStan reject their code.
- PHPStan reports that a method returning array_filter($items) does not match @return list<Item>; why, and what is the fix?`array_filter()` preserves the original keys, so removing elements leaves gaps and the result is no longer a list. Wrap it in `array_values()`, which reindexes from 0, or change the declared type to `array<int, Item>` if the keys genuinely do not need to be sequential.
- What does array{id: int, ...} mean in PHPStan 2.2?An unsealed shape: the key `id` must be an `int`, and any other keys are allowed with `mixed` values. `...<string, int>` would constrain the extra keys and values. Without `...`, a shape describes exactly the listed keys, which Bleeding Edge in 2.2 enforces strictly.
saying these in an interview costs you the question
- list<T> and array<int, T> are the same type
- array_filter() on a list returns a list
- PHPDoc types change how PHP behaves at runtime
- Array shapes cannot express optional keys
- Native PHP supports array<int, string> as a declaration