deftcode
Polski
← Back to blog

PHP in a nutshell: Arrays

Read this in Polish
phpphp coursearrays

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..n

The “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 1
array_is_list($a); // false (PHP 8.1)
json_encode($a); // {"0":1,"2":3} — an OBJECT, not a JSON array
array_is_list(array_values($a)); // true

The 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 survived

Unpacking 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 = 30

The 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 = 3

Right 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 too

Nesting 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 array
array_map(null, [1, 2], ['x', 'y']); // [[1, 'x'], [2, 'y']] — without a callback it pairs them up
array_filter($a, fn($v) => $v > 1); // [0 => 3, 2 => 2] ← keys with a gap
array_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 VALUE
array_search(3, $a, true); // 0 — the same, but returns the KEY
array_key_exists('a', $assoc); // true — searches for a KEY, not a value
// the third argument is STRICT comparison (===); without it, loose (==) is used
in_array('3', $a); // true — '3' == 3
in_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 exists
isset($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 wins
array_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 + 2
array_product($a); // 6 — product of values: 3 * 1 * 2
count($a); // 3 — number of elements
// ── Stack and queue ──────────────────────────────────────────────────────────
array_push($a, 4); // adds at the END
array_pop($a); // removes from the END and returns the removed value
array_unshift($a, 0); // adds at the BEGINNING
array_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 right
array_intersect([1, 2, 3], [2, 3, 4]); // [1 => 2, 2 => 3] — what's in both
array_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 STRINGS
array_flip(['a' => 1, 'b' => 2]); // [1 => 'a', 2 => 'b'] — swaps keys with values

Two 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 id

array_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 8
in_array('1', [1, 2]); // true — '1' and 1 are loosely equal
in_array('1', [1, 2], true); // false — the third argument enables ===

Always pass true as the third argument to in_array(), array_search() and array_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 NULL
array_diff_assoc($a, $b); // compares KEY and value, not just the value
array_unique($a); // compares after casting to STRING by default

The 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 out
array_diff_assoc($a, $b); // ['x' => 1, 'y' => 2] — the pair 'x' => 1 doesn't exist in $b

array_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 remains
array_unique([1, '1', true], SORT_REGULAR); // [0 => 1, 2 => true] — compares without casting

The 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”, and O(n) means “cost grows in direct proportion to the number of elements”. So the latter is noticeably more expensive than the former, and O(n²) even more so.

// ❌ O(n²) on a large array
while ($item = array_shift($queue)) { }
// ✅ linear
foreach ($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 modified
sort($a); // ✅ this is how you use it

The 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 discarded
Diagram: array_merge overwrites colliding keys with values from the right array and renumbers integer keys, while the plus operator keeps the values from the left
On a key collision, array_merge() takes the value from the right, while the + operator keeps the left one.

Two 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 precedence

To 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.

Comments