PHP in a nutshell: Arrays
One structure instead of several
In most languages a list, a map and a set are three separate types: in Java List, Map and Set; in C# List,
Dictionary and HashSet; in JavaScript and TypeScript Array, Map and Set. PHP will surprise you with its
simplicity and its inconsistency at the same time: a single array type replaces them all, and on top of that it
also serves as a stack and a queue.
Technically, a PHP array is an ordered hash map. It remembers insertion order, and at the same time maps keys to
values. There’s no separate list type — a “list” is simply an array that happens to have the keys 0, 1, 2, …
with no gaps.
$indexed = [1, 2, 3]; // keys 0, 1, 2$assoc = ['name' => 'Paweł', 'age' => 30];$nested = ['user' => ['roles' => ['admin']]];
echo $nested['user']['roles'][0]; // 'admin'That one phrase — “ordered hash map” — explains most of the odd behaviour you’ll see below. Whenever something surprises you, come back to it.
A hash map is a structure that holds key → value pairs and can find a value by its key without scanning the entire contents. The key is converted into a number (a hash), which points straight to the place where the value is stored — which is why
$a['name']costs the same with ten elements as with a million. A classic hash map makes no promises about element order. PHP does — and that’s exactly why it’s “ordered”.
Keys
Only int and string are allowed
Everything else is silently cast:
| Key given | Actual key |
|---|---|
'8' |
8 — a numeric string becomes an int |
'08' |
'08' — leading zero, so it stays a string |
8.7 |
8 — fractional part truncated |
true / false |
1 / 0 |
null |
'' — empty string |
| object / array | TypeError |
The first row is the most common source of surprise. $a['8'] and $a[8] are the same key, so an array built
from input data can silently merge two fields:
$a = [];$a['8'] = 'first';$a[8] = 'second';
count($a); // 1 — not 2$a[8]; // 'second'Pay attention to '08': only the canonical form of an integer is converted. '08', '8.0', ' 8'
and '+8' stay strings.
Appending and removing
$a = [];$a[] = 'x'; // key 0$a[10] = 'y';$a[] = 'z'; // key 11 — the highest INTEGER key + 1, not count()
unset($a[0]); // removes the key, does NOT renumber the rest$a = array_values($a); // only this renumbers to 0..nThe “highest integer key so far plus one” rule holds even after an element is removed — the counter doesn’t go back.
That’s why an array after unset() or array_filter() has gaps and stops being a list.
$a = [1, 2, 3];unset($a[1]);
$a; // [0 => 1, 2 => 3] — a gap at position 1array_is_list($a); // false (PHP 8.1)json_encode($a); // {"0":1,"2":3} — an OBJECT, not a JSON arrayarray_is_list(array_values($a)); // trueThe last line is a real problem in APIs. As long as the array is a list, json_encode() produces […]. One gap in
the keys and the same code starts returning {…} — and the client on the other end blows up. After every
array_filter() on a list, use array_values().
Unpacking and destructuring
The ... operator
// string keys: on collision the one on the right wins$defaults = ['a' => 1];$options = ['a' => 9, 'b' => 2];
[...$defaults, ...$options]; // ['a' => 9, 'b' => 2] — 'a' => 1 is gone
// integer keys: renumbered from zero, so a collision doesn't swallow anything$x = [5 => 'a', 9 => 'b'];$y = [5 => 'c'];
[...$x, ...$y]; // [0 => 'a', 1 => 'b', 2 => 'c'] // keys 5 and 9 are gone, but all three values survivedUnpacking works since PHP 7.4 for integer keys, and only since 8.1 for string keys. The difference between the two cases matters: a string key overwrites the value on collision, while an integer key can’t collide, because it gets renumbered from zero anyway.
Destructuring by key
$user = ['name' => 'Paweł', 'age' => 30];
['name' => $n, 'age' => $g] = $user; // $n = 'Paweł', $g = 30The order in such a pattern doesn’t matter — the names do. This is the variant you’ll reach for most often with data from a database or a request.
Positional destructuring
$row = [1, 2, 3];
[$first, , $third] = $row; // a double comma SKIPS an element // $first = 1, $third = 3Right inside foreach
This is the use case that genuinely shortens code:
$rows = [ ['id' => 1, 'name' => 'A'], ['id' => 2, 'name' => 'B'],];
foreach ($rows as ['id' => $id, 'name' => $name]) { echo "$id: $name";}
foreach ([[1, 2], [3, 4]] as [$left, $right]) { } // positional works tooNesting and swapping without a temporary variable
[[$x, $y], [$z]] = [[1, 2], [3]]; // $x=1, $y=2, $z=3['a' => ['b' => $deep]] = ['a' => ['b' => 'found']]; // $deep = 'found'
$arr = [1, 2, 3];[$arr[2], $arr[0]] = [$arr[0], $arr[2]]; // [3, 2, 1]A missing key in the pattern is a warning, not an error — the variable gets
null.['x' => $x] = ['y' => 1];gives$x === null. There’s no syntax for a default value, so when a key is optional, just write$x = $arr['x'] ?? 'default';.
list($a, $b) = $arr; is the old spelling of the same thing. In new code, use square brackets.
The array_* functions
There are more than eighty of them; below are the ones you’ll run into most often in day-to-day work.
$a = [3, 1, 2];$assoc = ['a' => 1, 'b' => 2];
// ── Mapping, filtering, reducing ─────────────────────────────────────────────array_map(fn($v) => $v * 2, $a); // [6, 2, 4]array_map(fn($x, $y) => $x + $y, [1, 2], [10, 20]); // [11, 22] — the callback gets one // element from EACH arrayarray_map(null, [1, 2], ['x', 'y']); // [[1, 'x'], [2, 'y']] — without a callback it pairs them uparray_filter($a, fn($v) => $v > 1); // [0 => 3, 2 => 2] ← keys with a gaparray_filter($assoc, fn($k) => $k === 'a', ARRAY_FILTER_USE_KEY); // ['a' => 1]array_reduce($a, fn($carry, $v) => $carry + $v, 0); // 6
// ── Keys and values ──────────────────────────────────────────────────────────array_keys($assoc); // ['a', 'b']array_values($assoc); // [1, 2]array_key_first($assoc); // 'a'array_key_last($assoc); // 'b'
// ── Is it in there ───────────────────────────────────────────────────────────in_array(3, $a, true); // true — searches for a VALUEarray_search(3, $a, true); // 0 — the same, but returns the KEYarray_key_exists('a', $assoc); // true — searches for a KEY, not a value
// the third argument is STRICT comparison (===); without it, loose (==) is usedin_array('3', $a); // true — '3' == 3in_array('3', $a, true); // false — '3' !== 3, string versus int
// array_key_exists() tells "no such key" apart from "the key is there, but the value is null"$withNull = ['a' => null];array_key_exists('a', $withNull); // true — the key existsisset($withNull['a']); // false — isset() treats null as missing
// ── Combining ────────────────────────────────────────────────────────────────array_merge(['a' => 1], ['a' => 2, 'b' => 3]); // ['a' => 2, 'b' => 3] — right wins['a' => 1] + ['a' => 2, 'b' => 3]; // ['a' => 1, 'b' => 3] — left winsarray_combine(['x', 'y'], [10, 20]); // ['x' => 10, 'y' => 20]
// ── Slicing and splitting ────────────────────────────────────────────────────array_slice([1, 2, 3, 4, 5], 1, 3); // [2, 3, 4]array_chunk([1, 2, 3, 4, 5], 2); // [[1, 2], [3, 4], [5]]range('a', 'e'); // ['a', 'b', 'c', 'd', 'e']
// ── Rows and columns ─────────────────────────────────────────────────────────$rows = [['id' => 7, 'name' => 'A'], ['id' => 9, 'name' => 'B']];array_column($rows, 'name'); // ['A', 'B']array_column($rows, 'name', 'id'); // [7 => 'A', 9 => 'B']
// ── Aggregates ───────────────────────────────────────────────────────────────array_sum($a); // 6 — sum of values: 3 + 1 + 2array_product($a); // 6 — product of values: 3 * 1 * 2count($a); // 3 — number of elements
// ── Stack and queue ──────────────────────────────────────────────────────────array_push($a, 4); // adds at the ENDarray_pop($a); // removes from the END and returns the removed valuearray_unshift($a, 0); // adds at the BEGINNINGarray_shift($a); // removes from the BEGINNING and returns the removed value
// ── Differences and intersections ────────────────────────────────────────────array_diff([1, 2, 3], [2]); // [0 => 1, 2 => 3] — what's in the left, but not in the rightarray_intersect([1, 2, 3], [2, 3, 4]); // [1 => 2, 2 => 3] — what's in botharray_diff_key($assoc, ['a' => 0]); // ['b' => 2] — compares KEYS, not values
// ── Unique and flip ──────────────────────────────────────────────────────────array_unique([1, '1', 2]); // [0 => 1, 2 => 2] — compares as STRINGSarray_flip(['a' => 1, 'b' => 2]); // [1 => 'a', 2 => 'b'] — swaps keys with valuesTwo things from the mapping section are worth expanding on. First, array_map() accepts any number of arrays —
the callback then receives one element from each, taken in turn from the same positions. Second, passing null
instead of a callback makes PHP transform nothing and just pair those arrays up — doing what other languages call
zip.
Three worth knowing well
array_column() turns a query result into a map and saves you a loop:
$rows = [ ['id' => 7, 'name' => 'A'], ['id' => 9, 'name' => 'B'],];
array_column($rows, 'name'); // ['A', 'B']array_column($rows, 'name', 'id'); // [7 => 'A', 9 => 'B']array_column($rows, null, 'id'); // [7 => […], 9 => […]] — whole rows by idarray_filter() without a callback removes everything falsy — including 0 and '0':
array_filter([0, 1, '', '0', 'a', null]); // [1 => 1, 4 => 'a']If you only want to drop null, say so explicitly:
array_filter($a, fn($v) => $v !== null);in_array() compares loosely by default — and you already know from the previous part what that means:
in_array('abc', [0, 1]); // false in PHP 8 (in PHP 7 it was true!)in_array(0, ['a', 'b']); // false in PHP 8in_array('1', [1, 2]); // true — '1' and 1 are loosely equal
in_array('1', [1, 2], true); // false — the third argument enables ===Always pass
trueas the third argument toin_array(),array_search()andarray_keys(). The PHP 8 change removed the worst cases, but loose comparison can still surprise you.
Other pitfalls
$a[-1]; // in an ARRAY it's simply the key -1, not indexing from the end$str[-1]; // but a STRING is indexed from the end — this is the last character
array_push($a, 1, 2); // by reference, several values at once$a[] = 1; // for a single value, faster and more readable
array_reduce($a, $cb); // without the third argument the accumulator starts at NULLarray_diff_assoc($a, $b); // compares KEY and value, not just the valuearray_unique($a); // compares after casting to STRING by defaultThe first argument of the array_reduce() callback is the accumulator — the value carried over from one iteration
to the next, called $carry in the PHP documentation. Without the third argument it starts at null, so when adding
up numbers it’s worth passing 0 explicitly.
The difference between array_diff() and array_diff_assoc() is easiest to see in an example — the first looks only
at values, the second requires the key-value pair to match:
$a = ['x' => 1, 'y' => 2];$b = ['z' => 1];
array_diff($a, $b); // ['y' => 2] — 1 is in $b, so it drops outarray_diff_assoc($a, $b); // ['x' => 1, 'y' => 2] — the pair 'x' => 1 doesn't exist in $barray_unique() deserves an example, too. By default it converts values to strings before comparing, so 1,
'1' and true look identical to it:
array_unique([1, '1', true]); // [0 => 1] — one element remainsarray_unique([1, '1', true], SORT_REGULAR); // [0 => 1, 2 => true] — compares without castingThe cost of operations at the start of an array
array_shift() and array_unshift() renumber all integer keys, so they cost O(n) — inside a loop over an array
that’s O(n²). array_push() and array_pop() work at the end and are O(1).
If the
O(…)notation is new to you, it’s big O notation — a way of describing how the cost of an operation grows with the number of elements. It’s worth reading up on at some point, but for now one intuition is enough:O(1)means “constant cost, independent of size”, andO(n)means “cost grows in direct proportion to the number of elements”. So the latter is noticeably more expensive than the former, andO(n²)even more so.
// ❌ O(n²) on a large arraywhile ($item = array_shift($queue)) { }
// ✅ linearforeach ($queue as $item) { }Sorting
In PHP, sorting functions mutate the array in place and return a bool, not the sorted array. That’s the first
thing that confuses people coming from other languages:
$sorted = sort($a); // ❌ $sorted is true, and $a has been modifiedsort($a); // ✅ this is how you use itThe names look chaotic, but there’s a system to them: the prefix a means keys are preserved (associative), k
sorting by key, u a custom callback (user), and r descending order (reverse).
| Function | Sorts by | Preserves keys |
|---|---|---|
sort() / rsort() |
value | ❌ renumbers |
asort() / arsort() |
value | ✅ |
ksort() / krsort() |
key | ✅ |
usort() |
custom callback, by value | ❌ |
uasort() |
custom callback, by value | ✅ |
uksort() |
custom callback, by key | ✅ |
natsort() / natcasesort() |
natural order (img12 after img2) |
✅ |
array_multisort() |
multiple arrays or columns | ❌ |
The callback returns a negative number, zero or a positive number — exactly what the <=> operator from the previous
part gives you:
usort($users, fn($a, $b) => $a->age <=> $b->age); // ascending
usort($users, fn($a, $b) => [$a->last, $a->first] // by several fields at once <=> [$b->last, $b->first]);The second example relies on the fact that <=> compares arrays element by element — you get sorting by last name,
and on a tie by first name, without writing any ifs.
Since PHP 8.0 sorting is stable: equal elements keep their original order. Before that the order was undefined, so code that sorted twice by different fields gave different results on different versions.
array_merge() versus the + operator
This is the difference that trips up the most people, so I’ve saved it for last.
array_merge(['a' => 1, 'b' => 2], ['b' => 3]); // ['a'=>1, 'b'=>3] — RIGHT wins['a' => 1, 'b' => 2] + ['b' => 3, 'c' => 4]; // ['a'=>1, 'b'=>2, 'c'=>4] — LEFT wins
array_merge([1, 2], [3]); // [1, 2, 3] — integer keys RENUMBERED[1, 2] + [3]; // [1, 2] — key 0 collides, the right side is discardedTwo rules are all you need to remember:
array_merge()— string keys are overwritten by the right side, integer keys are renumbered, and nothing gets lost.+— the left side takes precedence and no key is changed, so colliding elements from the right simply disappear.
Hence the practical uses: + is for filling in default values, not for concatenating lists.
$config = $userConfig + $defaults; // the user's settings take precedenceTo concatenate lists, use array_merge() or unpacking [...$a, ...$b] — both renumber keys
and don’t lose any elements.
What’s next
You now know values, types and the structure that holds them. Time for what you actually do with them.
In the next part we’ll look at operators — including the precedence trap that makes and and or
not interchangeable with && and || — and control flow: loops, switch versus match, and what
really sets require apart from include.