Arrays: One Structure, Two Jobs
A PHP array is an ordered hash map. The same type is a list, a dictionary, a set, a stack and a queue, which is convenient and is why the function names are inconsistent. This lesson is the subset worth knowing by heart.
Lists and maps are the same type
<?php
declare(strict_types=1);
$list = ['a', 'b', 'c']; // keys 0, 1, 2
$map = ['name' => 'Aisha', 'age' => 29];
$mixed = ['a', 'key' => 'b', 'c']; // keys 0, 'key', 1
var_dump(array_is_list($list)); // true
var_dump(array_is_list($map)); // false
var_dump(array_is_list($mixed)); // false
echo count($list), PHP_EOL; // 3
echo json_encode($list), PHP_EOL; // ["a","b","c"]
echo json_encode($map), PHP_EOL; // {"name":"Aisha","age":29}
Open this example in the editor and change it. Nothing you type is saved.
array_is_list() is the function that tells you which you have, and it matters: json_encode produces a JSON array for a list and a JSON object for anything else. An API that returns [] for an empty result and {"2": ...} after a filter is the single most common PHP-to-JavaScript bug, and this is why.
<?php
declare(strict_types=1);
$rows = [1, 2, 3, 4];
$even = array_filter($rows, fn (int $n): bool => $n % 2 === 0);
echo json_encode($even), PHP_EOL; // {"1":2,"3":4} - an object!
echo json_encode(array_values($even)), PHP_EOL; // [2,4] - a list again
Open this example in the editor and change it. Nothing you type is saved.
array_filter preserves keys. Always wrap it in array_values() when the result is going to JSON or to anything that expects a list.
The functions worth knowing
<?php
declare(strict_types=1);
$numbers = [1, 2, 3, 4, 5];
print_r(array_map(fn (int $n): int => $n * $n, $numbers));
print_r(array_values(array_filter($numbers, fn (int $n): bool => $n > 2)));
echo array_reduce($numbers, fn (int $carry, int $n): int => $carry + $n, 0), PHP_EOL;
echo array_sum($numbers), PHP_EOL;
var_dump(in_array(3, $numbers, true));
var_dump(array_search(3, $numbers, true)); // 2 - the KEY
var_dump(array_key_exists(0, $numbers));
var_dump(isset($numbers[0]));
Open this example in the editor and change it. Nothing you type is saved.
isset() and array_key_exists() differ on one case, and it is the case that matters:
<?php
declare(strict_types=1);
$row = ['a' => 1, 'b' => null];
var_dump(isset($row['b'])); // false - the value is null
var_dump(array_key_exists('b', $row)); // true - the key is there
var_dump($row['b'] ?? 'default'); // 'default' - ?? is isset-based
Open this example in the editor and change it. Nothing you type is saved.
For a database row where null is a legitimate value, isset() will tell you a column is absent when it is merely empty. Use array_key_exists() when you need to know whether the key exists.
Keys, values and columns:
<?php
declare(strict_types=1);
$users = [
['id' => 1, 'name' => 'Aisha', 'team' => 'red'],
['id' => 2, 'name' => 'Noshad', 'team' => 'blue'],
['id' => 3, 'name' => 'Sam', 'team' => 'red'],
];
print_r(array_column($users, 'name')); // ['Aisha','Noshad','Sam']
print_r(array_column($users, 'name', 'id')); // [1=>'Aisha', 2=>'Noshad', ...]
print_r(array_column($users, null, 'id')); // whole rows, keyed by id
print_r(array_unique(array_column($users, 'team')));
print_r(array_count_values(array_column($users, 'team'))); // ['red'=>2,'blue'=>1]
Open this example in the editor and change it. Nothing you type is saved.
array_column($rows, null, 'id') is the idiomatic "index these rows by id" and it replaces a four-line foreach.
Merging, and the one trap:
<?php
declare(strict_types=1);
$defaults = ['host' => 'localhost', 'port' => 3306];
$override = ['port' => 5432, 'user' => 'app'];
print_r(array_merge($defaults, $override)); // right wins on string keys
print_r($defaults + $override); // LEFT wins - the union op
print_r(array_merge([1, 2], [3, 4])); // [1,2,3,4] - renumbered!
print_r([1, 2] + [3, 4, 5]); // [1,2,5] - keys preserved
Open this example in the editor and change it. Nothing you type is saved.
array_merge renumbers integer keys; + keeps them and the left side wins. For configuration, array_merge($defaults, $given) is almost always what you want; + is for when the keys are meaningful and must not move.
For nested configuration there is no built-in deep merge, and array_merge_recursive is probably not it - it turns two scalars with the same key into an array of both rather than overwriting.
Sorting says what to compare
<?php
declare(strict_types=1);
$numbers = [10, 9, 100, 1];
sort($numbers); // in place, returns bool, reindexes
print_r($numbers); // [1, 9, 10, 100]
$words = ['banana', 'apple', 'Cherry'];
sort($words);
print_r($words); // ['Cherry','apple','banana'] - ASCII
usort($words, fn (string $a, string $b): int => strcasecmp($a, $b));
print_r($words); // ['apple','banana','Cherry']
$scores = ['aisha' => 91, 'noshad' => 78, 'sam' => 64];
asort($scores); // by value, KEEPING keys
print_r($scores);
ksort($scores); // by key
print_r($scores);
Open this example in the editor and change it. Nothing you type is saved.
The family is regular once you see the pattern: sort/rsort by value discarding keys, asort/arsort by value keeping keys, ksort/krsort by key, usort/uasort/uksort with your own comparator. All of them mutate and return a boolean.
Sorting by two keys, and the stability guarantee:
<?php
declare(strict_types=1);
$rows = [
['team' => 'red', 'score' => 7],
['team' => 'blue', 'score' => 9],
['team' => 'red', 'score' => 9],
['team' => 'blue', 'score' => 7],
];
usort($rows, fn (array $a, array $b): int =>
[$b['score'], $a['team']] <=> [$a['score'], $b['team']]);
foreach ($rows as $row) {
echo $row['team'], ' ', $row['score'], PHP_EOL;
}
// blue 9
// red 9
// blue 7
// red 7
Open this example in the editor and change it. Nothing you type is saved.
Comparing two arrays with <=> compares element by element, which gives you a multi-key sort in one expression. Since PHP 8.0 all sorts are stable, so equal elements keep their original order - which means you can also sort twice, secondary key first.
Copying, and the one reference case
Arrays are value types: assigning copies. PHP does this lazily, so the copy costs nothing until one side is written to.
<?php
declare(strict_types=1);
$original = [1, 2, 3];
$copy = $original;
$copy[] = 4;
print_r($original); // [1, 2, 3] - untouched
print_r($copy); // [1, 2, 3, 4]
function addOne(array $values): array {
$values[] = 99; // a copy; the caller is safe
return $values;
}
addOne($original);
print_r($original); // still [1, 2, 3]
Open this example in the editor and change it. Nothing you type is saved.
That is the opposite of PHP objects, which are handles, and it is the single biggest difference from JavaScript and Python. It also means an array holding objects copies the handles, so the objects themselves are shared.
The one place it bites is foreach by reference:
<?php
declare(strict_types=1);
$values = [1, 2, 3];
foreach ($values as &$value) {
$value *= 2;
}
unset($value); // ALWAYS unset after
foreach ($values as $value) {
// without the unset above, the last element is overwritten here
}
print_r($values); // [2, 4, 6]
Open this example in the editor and change it. Nothing you type is saved.
Without the unset, $value is still a reference to the last element, and the next foreach assigns each item into it in turn - leaving [2, 4, 4]. It is a famous bug and the unset is the whole fix.
A worked example
<?php
declare(strict_types=1);
$events = [
['user' => 'aisha', 'action' => 'view', 'page' => '/courses', 'ms' => 31],
['user' => 'noshad', 'action' => 'view', 'page' => '/courses', 'ms' => 44],
['user' => 'aisha', 'action' => 'enrol', 'page' => '/courses/css', 'ms' => 210],
['user' => 'sam', 'action' => 'view', 'page' => '/about', 'ms' => 19],
['user' => 'aisha', 'action' => 'view', 'page' => '/courses/css', 'ms' => 27],
['user' => 'noshad', 'action' => 'enrol', 'page' => '/courses/js', 'ms' => 188],
['user' => 'sam', 'action' => 'view', 'page' => '/courses', 'ms' => 35],
];
// 1. Count by action, without a loop.
$byAction = array_count_values(array_column($events, 'action'));
print_r($byAction); // ['view'=>5, 'enrol'=>2]
// 2. Group, which still wants a loop - and the ??= makes it one line.
$pagesByUser = [];
foreach ($events as $event) {
$pagesByUser[$event['user']] ??= [];
$pagesByUser[$event['user']][$event['page']] = true;
}
ksort($pagesByUser);
foreach ($pagesByUser as $user => $pages) {
printf("%-8s %d page(s): %s%s",
$user, count($pages), implode(', ', array_keys($pages)), PHP_EOL);
}
// 3. Set algebra over columns.
$viewers = array_unique(array_column(
array_filter($events, fn (array $e): bool => $e['action'] === 'view'), 'user'));
$enrollers = array_unique(array_column(
array_filter($events, fn (array $e): bool => $e['action'] === 'enrol'), 'user'));
echo 'viewed but never enrolled: ',
implode(', ', array_values(array_diff($viewers, $enrollers))), PHP_EOL; // sam
echo 'did both: ',
implode(', ', array_values(array_intersect($viewers, $enrollers))), PHP_EOL;
// 4. Aggregate per page, sorted by total time descending.
$byPage = [];
foreach ($events as $event) {
$page = $event['page'];
$byPage[$page] ??= ['page' => $page, 'hits' => 0, 'ms' => 0];
$byPage[$page]['hits']++;
$byPage[$page]['ms'] += $event['ms'];
}
$ranked = array_values($byPage);
usort($ranked, fn (array $a, array $b): int =>
[$b['ms'], $a['page']] <=> [$a['ms'], $b['page']]);
foreach ($ranked as $index => $row) {
printf("%d. %-14s %d hit(s), %4dms, mean %5.1fms%s",
$index + 1, $row['page'], $row['hits'], $row['ms'],
$row['ms'] / $row['hits'], PHP_EOL);
}
// 5. Index by a key, for lookup - and note the array stays a list
// only because of array_values.
$slowest = array_column($ranked, null, 'page');
printf("/about took %dms%s", $slowest['/about']['ms'], PHP_EOL);
// 6. The JSON trap, demonstrated. These two rows are at positions 1
// and 3, so the filtered array's keys are 1 and 3 - and that is an
// object to json_encode, not a list.
$onlyOnce = array_filter($ranked, fn (array $r): bool => $r['hits'] === 1);
echo implode(',', array_keys($onlyOnce)), PHP_EOL; // 1,3
echo json_encode(array_column($onlyOnce, 'page')), PHP_EOL; // a list
var_dump(array_is_list($onlyOnce)); // false
var_dump(array_is_list(array_values($onlyOnce))); // true
Open this example in the editor and change it. Nothing you type is saved.
Step four is the shape to keep: ??= to initialise a bucket, accumulate in the loop, then array_values before usort so the result is a list. Step six is the reminder that array_filter leaves holes in the keys, and that json_encode notices.
When it goes wrong
| Symptom | Cause |
|---|---|
| JSON returned an object where a list was expected | array_filter kept the keys; use array_values |
| The last element duplicated after a loop | foreach by reference without unset |
isset() said a column was missing |
The value is null; use array_key_exists |
| Keys disappeared after merging | array_merge renumbers integer keys; use + |
sort() returned true, not the array |
It sorts in place and returns a boolean |
| 100 sorted before 9 | String comparison; use SORT_NUMERIC or a comparator |
| Changing a copy changed the original | The array holds objects, which are handles |
array_merge_recursive made arrays of scalars |
That is what it does; write your own deep merge |
A check you can run
<?php
$values = [1, 2, 3];
foreach ($values as &$value) { $value *= 2; }
foreach ($values as $value) { }
print_r($values);
Open this example in the editor and change it. Nothing you type is saved.
It prints [2, 4, 4]. The second loop assigned each element into $value, which is still a reference to the last slot.
Add unset($value); between the loops and it prints [2, 4, 6]. That one line is the entire fix, and seeing the wrong answer once is what makes it a habit.