One Structure, Two Shapes: Lists and Maps on an Ordered Hash Table
Every other language makes you choose: a list for sequences, a hash map for keys. PHP has one type — the array — which is an ordered map from keys (integers or strings) to values (anything). The two shapes you know from other languages are just conventions inside it:
$tags = ['php', 'arrays', 'interview']; // list: keys 0, 1, 2 (implicit)
$user = ['id' => 42, 'name' => 'Ada', 'active' => true]; // map: explicit string keys
$grid = [[1, 2], [3, 4], [5, 6]]; // nested — any depth
The short [] syntax is what you should write everywhere; array() is the pre-5.4 constructor that survives only in legacy code and in one place you will still meet it — array() as an empty default is identical to [], so there is no reason left to prefer it.
The mechanism underneath explains every surprise the type produces.
Keys are int or string, nothing else. Integer-like string keys are cast to integers on insertion, which is why $row['0'] and $row[0] are the same element. Float keys truncate toward zero (1.9 becomes key 1); booleans cast to 0/1; null becomes ''. A float key silently colliding with an int key is a rare but unforgettable debugging session.
Insertion order is preserved, always. PHP arrays are ordered hash tables: lookups are O(1) by key, and iteration follows insertion order regardless of hash position. That combination — map speed with list predictability — is why a single foreach over a query result "just works" in the order the database returned.
Appending never reindexes:
$rows = [10, 20];
$rows[] = 30; // [10, 20, 30] — next implicit int key is max+1
unset($rows[0]); // [1 => 20, 2 => 30] — keys 1 and 2 SURVIVE the gap
$rows[] = 40; // [1 => 20, 2 => 30, 3 => 40]
The new element's key is computed from the highest existing integer key, not from the count — so after unset, keys keep their identity and a for ($i = 0; $i < count($a); $i++) loop walks over missing indexes and warns. The fixes are array_values($a) to reindex from zero, or foreach, which never cares.
Mixing shapes is legal but usually a smell: an array with keys 0, 1, 'total' behaves like a list until the first unset, at which point the integer keys become a sparse hazard. Codebases that keep "rows" as lists of maps (array<int, array<string, mixed>>, exactly the docblock shape from Defining Functions) get predictable behavior from every array function in the next topic.
One last structural fact you will hit with real data: arrays copy on assignment and on pass-by-value:
$b = $a; // full copy — modifying $b leaves $a alone
There is no reference semantics by default (unlike Python dicts or JS objects), which makes functions safe but means passing a million-row array into a function is a real cost. &$a in a signature opts into the alias explicitly, and COW (copy-on-write) means the actual copy is deferred until either side writes.
Reading Without Warnings: isset, empty, ??, and Destructuring
Direct access to a missing key is a Warning in PHP 8 (a notice in 7.x) that evaluates to null:
$email = $user['email']; // Warning: Undefined array key "email" … and $email is null
Prototypes survive this; production logs fill with it. The four safe access patterns differ in exactly one dimension — what they do about null values:
isset($user['email']) // false for missing key OR null value
array_key_exists('email', $user) // false ONLY for missing key
$u['email'] ?? 'n/a' // substitutes for missing OR null — no warning
empty($u['email']) // missing, null, '', 0, '0', or false → true
The rule that resolves most confusions: isset is a combined existence-and-non-null test; array_key_exists is pure existence. When a user row legitimately stores 'nickname' => null, isset says the key is absent — correct for "do we have a value to display?", wrong for "did the query return this column?". That distinction is a reliable interview question precisely because production code gets it wrong.
Nested access is where the payoff shows:
$city = $payload['customer']['address']['city'] ?? null; // no warning at any level
The ?? chain is null-safe across arbitrary depth — a single missing level short-circuits the whole chain to null. Before ?? (PHP 7.0), this required isset($p['customer']['address']['city']) ? ... : null, which is both longer and subtly different: it also refuses to substitute when the final value itself is null — which is usually what you want anyway, but you should know the two forms can disagree.
A related time-saver most developers never learn: isset accepts multiple arguments and is true only when every one of them is set and non-null:
if (isset($row['id'], $row['email'], $row['created_at'])) { … }
That replaces three separate checks with one call — handy when a row must have several columns before it is usable. (empty is limited to a single argument, being a language construct around one expression.)
The boundary version of all of this is decoding untrusted JSON, where safe access is not optional:
$payload = json_decode($raw, true) ?? []; // false → []
$name = trim($payload['user']['name'] ?? ''); // '' when missing at any level
$email = $payload['user']['email'] ?? null;
if ($name === '' || $email === null) {
return Result::invalid('name and email required');
}
Every read is warning-free, the missing-versus-null decision is explicit, and validation happens before anything downstream sees the data — the boundary discipline from the types topic, applied to array access.
Destructuring pulls arrays into variables in one line:
['id' => $id, 'name' => $name] = $row; // keyed destructuring (7.1+)
[$first, $second] = $pairList; // list destructuring
[$a, , $c] = $list; // skip an element with an empty slot
[$id, ...$rest] = $many; // the rest operator (7.1+)
Keyed destructuring is the everyday win with database rows and API payloads: it replaces four $row['x'] = ... assignments with one statement that also documents which keys the code actually uses. And for building, the spread operator works in literals too — [...$defaults, ...$overrides] merges two lists, and since PHP 8.1 it even carries string keys, so spreading two maps keeps the rightmost value for duplicate keys.
count, empty, and Membership: Guarding Collections Correctly
Three functions answer three different questions about an array, and conflating them is where the bugs come from.
How many elements? count($a) — O(1), because the count is maintained by the structure rather than computed per call, so calling it inside a loop does not add a second O(n) pass the way strlen on a growing string might. It accepts Countable objects too, which is why any class that is iterable-by-count can stand in for an array. The optional flag count($a, COUNT_RECURSIVE) counts nested elements as well, but its cost is proportional to the whole tree — fine for a small config, wrong for a million-row export.
Is it empty? empty($a) — true when the array has no elements. The trap is its name: empty tests general falsiness, so for non-array values it also answers true for 0, '0', '', false, and null. On arrays themselves that collapses to "has no elements", which is why if (empty($results)) reads fine — but on a mixed value it silently treats a legitimate 0 as absent. empty is the right guard for "nothing to process"; it is the wrong existence check for anything else.
Does this key exist? array_key_exists($k, $a) — pure membership, O(1), and the only one of the three that is honest about null values. Its isset cousin (isset($a[$k])) is also O(1) but merges existence with non-null. The performance-critical variant of membership is the one from the control-flow topic: using an array as a set —
$seen = [];
foreach ($ids as $id) {
if (isset($seen[$id])) { continue; } // O(1)
$seen[$id] = true;
}
— turns an O(n²) scan into O(n) because key lookup in PHP arrays is a hash probe. When you need the membership and the element, array_key_exists on the original array is the same O(1).
Two adjacent facts complete the toolkit. array_key_exists works on null-valued keys but emits a deprecation for null offsets on objects, and for objects generally the contract moves to the ArrayAccess interface: any class implementing offsetExists, offsetGet, offsetSet, and offsetUnset supports $obj[$k] syntax, at which point isset($obj[$k]) delegates to offsetExists — while empty() and ?? work through the same protocol. That is how framework collections keep array ergonomics without being arrays, and it is the bridge to the object topics ahead.
PHP 8.1 also added the missing classifier for the structural question these topics keep circling: array_is_list($a) answers "are the keys exactly 0..n-1 in order?" — the property that makes json_encode emit a JSON array and array_map zipping safe:
array_is_list([10, 20, 30]) // true
array_is_list([1 => 20, 2 => 30]) // false — a gap from unset
array_is_list(['id' => 1]) // false — a map
Before 8.1 you wrote that test by hand (array_keys($a) === range(0, count($a) - 1), which is O(n) and wrong for empty arrays); now it is O(1)-ish and unambiguous — exactly what to check after an array_filter when the consumer is json_encode.
Sizing generalizes too: count() and array_key_exists both accept Countable objects, so any class implementing Countable::count() participates in the same ecosystem — count($collection) works whether the thing is an array, a framework collection, or a PDO result wrapper.
The practical checklist for interviews and code review: count() for size (it is cheap), array_key_exists() when null is a legitimate value, isset() otherwise, and never a bare $a[$k] on data you did not just build yourself.
Quiz
1. $a = [1 => 'x']; $a[] = 'y'; unset($a[1]); $a[] = 'z'; — what are the final keys and values?
2. A row has 'nickname' => null. What does isset($row['nickname']) return, and what about array_key_exists?
3. Why is `$row['0']` the same element as `$row[0]`?
4. Which pair correctly describes count() and empty()?
Flashcards
Question
What is PHP's array in one sentence, and what do lists and maps have to do with it?
Click to reveal answer
Answer
An ordered map from int/string keys to any values, with insertion-order iteration and O(1) key lookup. Lists are maps with implicit sequential integer keys; maps use explicit string keys — the same underlying structure.
Question
Key casting rules on array insertion
Click to reveal answer
Answer
Integer-like strings become int ('0' === 0 as a key), floats truncate toward zero (1.9 → 1), true → 1, false → 0, null → ''. Integer-like string keys colliding after casting is a classic silent-overwrite bug.
Question
Why does $a[] after unset not reuse the removed key, and the fix?
Click to reveal answer
Answer
The next implicit key is the highest existing integer key plus one, not count-based, so gaps persist and for-loops over 0..count-1 hit undefined indexes. Fix with array_values($a) to reindex, or iterate with foreach which ignores keys.
Question
isset vs array_key_exists vs empty vs ?? — one line each
Click to reveal answer
Answer
isset: key exists AND value is not null. array_key_exists: key exists (null allowed). empty: general falsiness ('' , 0, '0', false, null, [] → true). ??: no-warning default for missing OR null; chains safely through nesting.
Question
Destructuring and spread essentials
Click to reveal answer
Answer
[$a, $b] = $list; ['id' => $id] = $row (keyed, 7.1+); [$first, , $third] skips a slot; [$head, ...$rest] collects the remainder. In literals, [...$a, ...$b] merges with the rightmost duplicate key winning — string keys included since 8.1.
Revision Notes
Key Takeaways
- 1. PHP arrays are ordered hash tables: O(1) key lookup, insertion-order iteration, one type for lists and maps
- 2. [] is the literal syntax; keys cast on insertion ('0' → 0, floats truncate), which creates silent collisions for unwary keys
- 3. unset preserves remaining keys and append uses max-key+1 — gaps survive until array_values reindexes
- 4. isset merges existence with non-null; array_key_exists is pure existence; ?? substitutes without warnings at any nesting depth
- 5. count() is O(1); arrays used as sets give O(1) membership and kill the nested in_array O(n²) pattern
Interview Tips
- • "Difference between isset and array_key_exists?" — null values: isset is existence AND non-null, array_key_exists is existence only; give the 'nickname' => null example and say which question each answers
- • "Why is my for-loop skipping elements?" — unset left key gaps and the next [] used max+1; explain the ordered-hash-table key rule and the array_values fix
- • "How do you merge two arrays with string keys?" — spread [...$a, ...$b] since 8.1 (rightmost duplicate wins); contrast array_merge, which renumbers integer keys — a real difference for lists
- • "How do you make membership O(1)?" — flip the list into an array-as-set ($seen[$value] = true) and use isset; state the complexity change from O(n²) to O(n)
Cheat Sheet
Indexed and Associative Arrays
One structure: an ordered map of int|string keys → any value. [] is the literal syntax; array() is legacy.
$tags = ['php', 'interview']; // list: keys 0, 1
$user = ['id' => 42, 'name' => 'Ada']; // map
$rows[] = $new; // append: next key = max int key + 1
Key casting: '0' → int 0 (same element!), float keys truncate, true → 1, null → ''. Insertion order is always preserved; lookups are O(1).
Gap trap: unset($a[0]) leaves keys [1 => …, 2 => …] — a for (0..count) loop then walks missing indexes. Reindex with array_values($a) or just use foreach.
Safe access — the difference is how each treats null:
isset($a[$k]) // false if missing OR value is null
array_key_exists($k,$a) // false ONLY if missing
$a[$k] ?? $default // no warning; substitutes for missing or null
empty($a[$k]) // missing/null/'0'/0/false/'' → true (general falsiness)
$p['a']['b'] ?? null // null-safe through any nesting depth
Destructuring & spread (7.1+, 8.1 for string keys):
['id' => $id, 'name' => $name] = $row; // keyed
[$first, , $third] = $list; // skip a slot
[$head, ...$tail] = $list; // rest
[...$defaults, ...$overrides] = $merged; // rightmost duplicate key wins (8.1 for string keys)
Sizing & membership: count() is O(1) (COUNT_RECURSIVE for trees), array_key_exists() for pure membership, isset() for "has a non-null value". Array-as-set ($seen[$id] = true) gives O(1) membership — the O(n²) killer.