Skip to content
intermediate Phase 3 · Arrays and String Processing

Array Functions for Data Transformation

Map, filter, and reduce collections without manual loops.

50m
0 problems
Topic Progress 0%

map and filter: Transforming Collections Without a foreach

The loop-heavy shape of most PHP code —

$out = [];
foreach ($users as $u) {
    if ($u['active']) {
        $out[] = strtoupper($u['name']);
    }
}

— is exactly two operations wearing one costume: a filter (keep the active ones) and a map (upper-case each name). PHP ships both as functions:

$names = array_map(
    fn (array $u) => strtoupper($u['name']),
    array_filter($users, fn (array $u) => $u['active'])
);

array_map applies a callback to every element and returns a new array of results; it never modifies the input. With a single input array it preserves the keys, which is why mapping over a string-keyed map keeps its shape. Give it two or more arrays and it zips them element-wise — but then keys are renumbered from zero:

$sums = array_map(fn (int $a, int $b) => $a + $b, $xs, $ys);   // list keys 0..n-1

(The old array_map(null, $xs, $ys) zip idiom is deprecated since PHP 8.0 — use the explicit callback, or array_map with a fn that takes two parameters as above.)

array_filter keeps elements whose callback returns true and drops the rest. Called without a callback it drops everything falsy — the quick array_filter($lines) idiom for removing empty strings. Its signature also accepts ARRAY_FILTER_USE_KEY and ARRAY_FILTER_USE_BOTH when the predicate needs the key ("skip reserved names") or both ("compare against the same-index threshold").

The key-preservation behavior of array_filter is the trap worth memorizing:

$rows = [10 => 'a', 11 => 'b', 12 => 'c'];
$kept = array_filter($rows, fn ($v) => $v !== 'b');   // [10 => 'a', 12 => 'c'] — key 11 is gone
echo $kept[11];                                       // Warning: undefined key

Filtered lists keep their original indexes, so anything downstream that expects 0..n-1 (a for loop, json_encode which will produce a JSON object instead of an array, array_map zipping against another list) breaks. The one-line cure is habitual: array_values(array_filter(...)) whenever the result is meant to be a list.

Two neighboring functions complete the pair. array_walk($a, $fn) calls a callback per element but mutates in place (useful for appending to elements by reference) and returns bool, so it does not compose into chains:

array_walk($rows, function (&$row, $i) use ($prefix) {
    $row['label'] = $prefix . $i;
});

Compare that with the arrow-function version of the same job and the readability gap is obvious — which is the second point about arrow functions: they capture outer variables automatically, by value, where the old syntax needed an explicit use ($prefix) clause. Capture by value is also a safety property: a callback cannot accidentally mutate the counters or configs it closed over, which is precisely what you want in a transform.

// pre-7.4 — the ceremony that kept people away from array_map
$labels = array_map(function ($row) use ($prefix) {
    return $prefix . $row['name'];
}, $rows);

// 7.4+ — one expression, capture is automatic and immutable
$labels = array_map(fn ($row) => $prefix . $row['name'], $rows);

Nesting the two functions composes multi-stage logic without intermediate variables — filter the active users, then map to their e-mail domains with substr(strrchr($u['email'], '@'), 1) — and each stage remains independently testable. When a stage needs three or more lines, stop nesting and go back to foreach; the point of the functions is to make simple transformations one-liners, not to encode algorithms in parentheses.

reduce, group, and count: Collapsing a List into One Answer

array_reduce walks a list carrying an accumulator, and returns the final carry — the canonical "many values in, one value out" operation:

$total = array_reduce(
    $order['items'],
    fn (float $sum, array $item) => $sum + $item['price'] * $item['qty'],
    0.0            // initial value — ALWAYS pass one
);

The third argument is the accumulator's starting value, and passing it explicitly is not optional advice: without it, array_reduce seeds the carry with the first element and starts from the second, which mangles both the types (string concatenation instead of numeric addition) and empty arrays (no elements at all means no result). A seed also makes the empty case behave: array_reduce([], $add, 0.0) is 0.0.

The same fold becomes a grouping operation when the carry is a map:

$byStatus = array_reduce(
    $orders,
    function (array $groups, array $o): array {
        $groups[$o['status']][] = $o;
        return $groups;
    },
    []
);
// ['paid' => [...], 'pending' => [...]]

That is the general shape of half the reporting code you will ever write: pick the carry's shape (a counter, a map of lists, a max-so-far) and the reduce callback writes one line per element.

Four built-ins deserve to replace hand-rolled reduces entirely:

array_combine($ids, $names)      // two lists → one map (lengths must match, else ValueError)
array_count_values($statuses)    // ['paid','paid','new'] → ['paid' => 2, 'new' => 1]
array_column($rows, 'email')     // one column → list (works on rows of arrays AND objects)
array_flip($map)                 // swap keys and values — fast reverse lookup

array_column in particular retires the classic map-then-reindex idiom (array_map(fn($r) => $r['email'], $rows)) and, unlike it, produces a clean list. array_flip turns a value set into a keyed map so membership tests become the O(1) isset probes from the previous topic.

For extraction with conditions, PHP 8.4 added the predicate-search family — array_find, array_find_key, array_any, array_all — which reads better than array_search for "first element matching this":

$first = array_find($users, fn (array $u) => $u['score'] > 90);   // element or null

On earlier versions the same job is array_search($x, $list, true) — remember it returns a key, or false, so the check must be === false or key 0 silently fails the test.

Set operations on lists are built in, and each has a default that bites:

array_diff($current, $keep)        // in $current but not $keep — string comparison
array_intersect($current, $keep)   // in both
array_unique($rows)                // keeps FIRST of each duplicate group, original keys intact

All three compare with loose rules by default (array_unique defaults to SORT_STRING), so two rows that differ only in types or in numeric-looking strings can collapse or survive unexpectedly. For arrays of arrays or objects there is no flag that does element-wise identity — the reliable pattern is to key by an identifier first (array_column($rows, null, 'id') uses the third argument to key rows by a column) and compare with isset, back to the O(1) habit.

Slicing tools split the difference between viewing and mutating:

array_slice($a, 10, 20)        // non-destructive: 20 items from offset 10 (keys reindexed)
array_splice($a, 10, 20)       // destructive: removes them from $a and RETURNS them
array_chunk($a, 100)           // batches of 100 — the pagination and bulk-insert idiom

array_slice preserves string keys but renumbers integer ones by default (preserve_keys: true to opt out); array_splice ignores keys entirely and always reindexes — which is exactly what you want when removing a range, and exactly the surprise you do not want when the array was a map.

Pipelines, Chaining, and When foreach Still Wins

The three functions compose into a pipeline that mirrors the query it replaces:

$topEmails = array_map(
    fn (array $u) => $u['email'],
    array_slice(
        array_filter($users, fn (array $u) => $u['active']),
        0, 5
    )
);

Read outside-in: filter the active users, take the first five, project the emails. Compare it with the equivalent two temp variables and two loops, and the pipeline's advantages are structural — no temp variables to name, no mutation, each stage independently testable, and the shape matches how you would say it aloud. The arrow function's auto-by-value capture is what keeps each stage to a single line.

The costs are real too, and knowing them separates "uses array functions" from "understands array functions":

Every stage allocates a new array. A chain over one million rows builds one million-element intermediates at each step. For data that fits comfortably in memory this is a non-issue — PHP's copy-on-write and refcounting keep it cheap — but for large or hot paths, a single foreach with early exits and several accumulators does the same work in one pass with zero intermediates:

$sum = 0.0; $count = 0;
foreach ($rows as $r) {
    if (! $r['active']) { continue; }
    $sum += $r['total'];
    $count++;
    if ($count >= 100) { break; }        // pipelines cannot stop early
}

Pipelines cannot short-circuit. "Find the first admin" through array_filter always scans the entire list; a foreach with break stops at the match. Similarly, when one pass needs two accumulators (min and max, count and sum) or must touch both the element and its neighbor, the loop is not just faster — it is clearer.

Sorting is its own world. usort($a, fn ($x, $y) => $x['score'] <=> $y['score']) sorts in place (note: not a pipeline stage — it returns bool), is stable since PHP 8.0, and is O(n log n) regardless of comparator cost. Multi-key sorts either use one comparator with ?: chaining or exploit stability with two passes, as in the control-flow topic.

The decision heuristic to carry into review: pipelines for whole-collection transforms on bounded data, foreach for early exit, multi-accumulator folds, or anything above ~100k elements. And either way, pass strict to every predicate that compares loosely — in_array($id, $ids, true) — because a pipeline of three correct functions still produces wrong output if its predicate matches 'abc' == 0 on an old runtime.

A realistic report-shaped example shows all three functions earning their place — group active orders by customer, then total each group:

$totals = [];
foreach ($orders as $o) {
    if (! $o['active']) { continue; }
    $totals[$o['customer_id']] = ($totals[$o['customer_id']] ?? 0.0) + $o['total'];
}
ksort($totals);                      // keyed map, sortable, json-object friendly

Written as a pipeline it becomes array_count_values-shaped logic that no single built-in covers, which is the honest lesson: the built-ins cover projection, selection, and folding — grouping and keyed accumulation are usually one foreach with a ?? default, and forcing them into array_reduce callbacks costs readability without buying anything. Reach for the functions when the operation is exactly one of their shapes; the moment you are passing multi-line closures into three nested calls, the loop was the better answer all along.

Quiz

1. array_filter is used on a list and the result is json_encode'd. The JSON is an object with keys "0" and "2" instead of an array. Why?

Question 1 options

2. Why must array_reduce usually be given an explicit initial value?

Question 2 options

3. You need the first user with score > 90. Which approach short-circuits?

Question 3 options

4. Which built-in converts rows of associative arrays into a clean list of one column?

Question 4 options

Flashcards

Question

array_map semantics: keys, multiple arrays, and the deprecated idiom

Answer

Applies a callback to every element and returns a new array, never mutating the input. With one array it preserves keys; with several it zips them and reindexes. array_map(null, $a, $b) as a zip idiom is deprecated since PHP 8.0 — pass an explicit callback.

Question

array_filter key preservation — the bug and the fix

Answer

Surviving elements keep their original keys, leaving gaps (keys 0 and 2 after removing 1). Anything expecting 0..n-1 breaks: for-loops, zip-style array_map, and json_encode (emits an object instead of an array). Fix: array_values(array_filter(...)).

Question

The array_reduce contract

Answer

array_reduce($a, fn($carry, $item) => …, $seed) folds the list into the carry. Always pass $seed: otherwise the first element becomes the carry and empty arrays yield null. A map-shaped carry turns the fold into a grouping operation.

Question

Which built-ins replace common hand-rolled folds?

Answer

array_combine (two equal-length lists → map), array_count_values (frequency map), array_column (one column → clean list, works on objects too), array_flip (reverse lookup map). Plus 8.4's array_find/array_find_key/array_any/array_all for predicate search.

Question

When is a single foreach better than a map/filter pipeline?

Answer

Early exit (break), multiple accumulators, >~100k elements (each stage allocates an intermediate array), or needing indexes/neighbors. Pipelines win for bounded, whole-collection transforms: no temporaries, each stage testable.

Revision Notes

Key Takeaways

  • 1. array_map transforms, array_filter selects, array_reduce folds — together they replace most map/filter/reduce loops on bounded data
  • 2. array_filter preserves keys, so filtered lists need array_values before anything expecting 0..n-1 — including json_encode
  • 3. array_reduce needs an explicit seed or the first element doubles as the accumulator and empty arrays return null
  • 4. array_column, array_count_values, array_combine, and array_flip retire entire categories of hand-written loops
  • 5. Pipelines allocate per stage and cannot short-circuit — foreach wins for early exit, multi-accumulator folds, and very large inputs

Interview Tips

  • • "Filter a list and encode it — gotcha?" — array_filter leaves key gaps and json_encode turns non-sequential keys into a JSON object; the fix is array_values; this shows you know both functions' contracts
  • • "Explain array_reduce to someone who knows fold/reduce" — carry plus callback plus seed; emphasize always passing the seed and show the grouping idiom (carry is a map of lists)
  • • "map/filter vs foreach performance?" — same O(n) per pass, but pipelines allocate an intermediate array per stage and cannot break early; quantify the threshold and name the memory shape
  • • "array_search pitfalls?" — returns a key (not an index guarantee) or false; must compare === false or key 0 fails; mention array_find on 8.4 as the element-returning replacement

Cheat Sheet

Array Functions for Data Transformation

The core three:

array_map(fn ($x) => f($x), $a)        // transform every element; keys preserved (1 array),
                                        // reindexed when zipping several
array_filter($a, fn ($x) => …)         // keep truthy results; keys PRESERVED — pipe through
                                        // array_values() for lists; no callback drops falsy
array_reduce($a, fn ($carry, $x) => …, $seed)   // fold to one value — always pass $seed

Arrow functions (7.4+): fn ($x) => $x * 2 — implicit return, auto capture-by-value. This is what makes map/filter one-liners readable.

Key-preservation trap:

$kept = array_filter($list, $pred);    // may leave [0 => …, 2 => …]
$kept = array_values(array_filter($list, $pred));   // clean list — do this habitually

Built-ins that retire hand-rolled loops:

Task Function
two lists → map array_combine($keys, $vals) (equal lengths)
frequency count array_count_values($list)
one column from rows array_column($rows, 'email') (arrays or objects)
reverse lookup array_flip($map) → isset() membership O(1)
first match (8.4) array_find, array_find_key, array_any, array_all
search array_search($v, $a, true) — returns key or false; compare === false

When to write a foreach instead: early exit (break), multiple accumulators (sum + count + max), >~100k elements (every pipeline stage allocates), or when you need the index and neighbors. Sorting: usort is in-place, O(n log n), stable since PHP 8.0.

Continue Learning