---
title: 'Multidimensional Arrays'
source: 'https://academia.sh/en/courses/php-fundamentals/multidimensional-arrays'
course: 'PHP Fundamentals'
language: en
updated: '2026-10-21T05:03:33+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Builds a nested data model by using arrays as array elements and accesses it with a key at every level'
    - 'Measures that a missing inner key produces E_WARNING and null and records it under the warning channel'
    - 'Shows that the null coalescing operator silences the whole path and that reading and writing are separate channels'
---

# Multidimensional Arrays

Nesting arrays inside arrays; a missing inner key giving null with an E_WARNING; array access on null; the null coalescing operator silencing the whole path; reading and writing being separate channels.

The Indexed and Associative Arrays lesson measured how different spellings of the same key land on a single element, and that no channel announces it. There the key was a single value. When an element is itself another array, access becomes a path: `$a[0]['surname']` performs two separate lookups, and the second depends on the result of the first. This lesson measures where it is heard when one link of that path is missing.

A multidimensional array is not a separate type; it is an ordinary array whose elements are arrays. The language sets no limit on depth, and only memory constrains it. The lesson uses the channel names in the sense they had in the previous lessons: **silent**, **return**, **warning**, **halts**.

## Modeling Nested Data

A list of students, each with a name and a table of grades, fits a nested structure. The file below builds this structure, reads from two levels, and compares the flat count from `count` with the recursive count.

```php
<?php
// students.php — modeling data with arrays inside arrays
$students = [
    ['name' => 'Ada', 'grades' => ['math' => 80, 'science' => 90]],
    ['name' => 'Can', 'grades' => ['math' => 70, 'science' => 60]],
];

foreach ($students as $position => $student) {
    $average = array_sum($student['grades']) / count($student['grades']);
    echo $position, ' ', $student['name'], ' ', $average, "\n";
}

echo 'Can science: ', $students[1]['grades']['science'], "\n";
echo 'flat count: ', count($students), "\n";
echo 'recursive count: ', count($students, COUNT_RECURSIVE), "\n";
```

```
0 Ada 85
1 Can 65
Can science: 60
flat count: 2
recursive count: 10
```

Each level has its own key space. The outer array is indexed, the student record is associative, and the grade table is associative again; `$students[1]['grades']['science']` is the application of three keys in sequence. `count` counts only the outer level, while the `COUNT_RECURSIVE` flag counts every inner array both as an element and through its contents; the difference between the two numbers therefore reflects the depth of the structure and not the number of data items.

**AS6.** In this lesson the records have a fixed shape: every student has a `name` and a `grades` key. The cases where the path breaks are built separately in the next section. Cases where the shape of a record comes from a user or from an external source belong to M16/K01 and are not covered here.

## A Missing Inner Key

What happens when the path breaks at one link is measured with the listener's channel order. The shared helper of the lesson, `channel.php`, is given below in full; since it has no output block, it is only written to disk.

```php
<?php
// channel.php — helpers that classify which channel a problem is heard through

// Level names of caught warnings collect here; the message text is not kept.
$GLOBALS['heard_warnings'] = [];

set_error_handler(function (int $level): bool {
    $names = [E_WARNING => 'E_WARNING', E_NOTICE => 'E_NOTICE', E_DEPRECATED => 'E_DEPRECATED'];
    $GLOBALS['heard_warnings'][] = $names[$level] ?? 'E_' . $level;
    return true;
});

// Turns a value into a short text together with its type.
function show(mixed $value): string
{
    return match (true) {
        $value === null => 'null',
        is_bool($value) => $value ? 'true' : 'false',
        is_array($value) => 'array(' . count($value) . ')',
        default => get_debug_type($value) . ' ' . $value,
    };
}

// Runs the job and asks, in order, which channel the problem is heard through:
// first a throw, then a warning, then a false/null return; if none of them, silent.
function listen(callable $job): array
{
    $GLOBALS['heard_warnings'] = [];
    try {
        $result = $job();
    } catch (Throwable $error) {
        return ['halts', get_class($error)];
    }
    if ($GLOBALS['heard_warnings']) {
        $levels = implode(',', array_unique($GLOBALS['heard_warnings']));
        return ['warning', $levels . ', result ' . show($result)];
    }
    if ($result === false || $result === null) {
        return ['return', 'result ' . show($result)];
    }
    return ['silent', 'result ' . show($result)];
}

// Runs the code in a separate PHP execution; exit code 255 means execution halted.
function run_separately(string $code): array
{
    exec('php -d display_errors=0 -r ' . escapeshellarg($code) . ' 2>&1', $lines, $exit);
    $channel = $exit === 255 ? 'halts' : 'silent';
    return [$channel, 'lines out ' . count($lines) . ', exit ' . $exit];
}
```

```php
<?php
// missing.php — a missing inner key and array access on null
require 'channel.php';

$records = [['name' => 'Ada']];

$observations = [
    'missing inner key'  => fn() => $records[0]['surname'],
    'missing outer key'  => fn() => $records[5]['name'],
    'access on null'     => fn() => $records[5]['name']['x'],
    'access on string'   => fn() => $records[0]['name']['x'],
];

foreach ($observations as $name => $observe) {
    [$channel, $evidence] = listen($observe);
    printf("%-23s%-9s%s\n", $name, $channel, $evidence);
}
```

```
missing inner key      warning  E_WARNING, result null
missing outer key      warning  E_WARNING, result null
access on null         warning  E_WARNING, result null
access on string       halts    TypeError
```

The first line is the course's `missing inner key` observation: `$records[0]['surname']` gave a `null` value and reported `E_WARNING`. The channel is **warning**. Execution continued; because the result is `null`, the calling code learns that something went wrong only from the warning that arrives with the value. This is the distinction between this lesson and the silence of the previous one: a loss in key writing was not heard, and a loss in key reading is heard.

The second line gives the same result for a missing index at the outer level, and the third line shows that, when the first link of the chain is already `null`, the second access reports the same level as well: array access on `null` is a warning and not an error, and `null` is returned again. However long the chain is, every access after a break quietly resolves to `null`; at which link the break occurred is read from the warning text and not from the result. The fourth line shows the boundary: when the path arrives at a string and a string key such as `'x'` is applied to that string, `null` is not returned and a `TypeError` is thrown; the channel is **halts**. The warning channel works only for `null` or a missing key, and not for access applied to a value of the wrong type.

**AS7.** The message text and line information of a warning depend on the environment, and the lesson does not print them; only the level name (`E_WARNING`) and the type of the result are printed. Turning the same break into an error that halts is possible with a setting, but the lesson relies on no ini value and does not measure that route.

## Silence with Null Coalescing

The null coalescing operator (`??`) gives the value on its left side, or the right side if the left is `null` or undefined. Applied to a missing path, it suppresses the warning at every link of the chain.

```php
<?php
// coalescing.php — ?? turns a missing path into silence
require 'channel.php';

$data = ['a' => 1];
$records = [['name' => 'Ada']];

$observations = [
    'plain read'      => fn() => $data['b'],
    'with coalescing' => fn() => $data['b'] ?? 0,
    'deep coalescing' => fn() => $records[3]['grades']['science'] ?? -1,
    'present value'   => fn() => $records[0]['name'] ?? 'none',
];

foreach ($observations as $name => $observe) {
    [$channel, $evidence] = listen($observe);
    printf("%-19s%-9s%s\n", $name, $channel, $evidence);
}
```

```
plain read         warning  E_WARNING, result null
with coalescing    silent   result int 0
deep coalescing    silent   result int -1
present value      silent   result string Ada
```

The same missing key was heard through two channels: the plain read is a **warning**, and with `??` it is **silent**. The third line matters more: `??` wraps not only the last link but the whole path; in a three-level path, if any link is missing, the result is the default value and no channel reports anything. This is the purpose of the operator, but the purpose also means that the problem disappears: if the path is misspelled (`'grade'` instead of `'grades'`), the `??` spelling covers up the mistake with the same silence.

## Working with a List of Records

In practice, nested arrays are often a list of records: the outer array is indexed, and each element is an associative array carrying the same keys. Three jobs are common for this shape: extracting a column, sorting the records by a key, and indexing the records by that column.

```php
<?php
// records.php — column, sorting and indexing on a list of records
$records = [
    ['id' => 7, 'name' => 'Can', 'age' => 31],
    ['id' => 3, 'name' => 'Ada', 'age' => 36],
    ['id' => 9, 'name' => 'Efe', 'age' => 24],
];

echo 'names: ', implode(',', array_column($records, 'name')), "\n";
echo 'indexed by id: ', implode(',', array_keys(array_column($records, 'name', 'id'))), "\n";

usort($records, fn($left, $right) => $left['age'] <=> $right['age']);
echo 'by age: ', implode(',', array_column($records, 'name')), "\n";

$records[1]['age'] += 1;
echo 'updated age: ', $records[1]['age'], "\n";
echo 'average age: ', array_sum(array_column($records, 'age')) / count($records), "\n";
```

```
names: Can,Ada,Efe
indexed by id: 7,3,9
by age: Efe,Can,Ada
updated age: 32
average age: 30.666666666667
```

`array_column` turns a column into a flat array; if a third argument is given, the keys of the result come from that column. `usort` rearranges the records according to the given comparison and renumbers the keys. Then `$records[1]['age'] += 1` writes to a two-level path; because the path exists, nothing is reported. Most code that works with a list of records is written with these three functions and two-level indexing; the mistake appears when the records are not all of the same shape.

**AS8.** In this lesson the shape of the records is fixed inside the code. Code that reads the same list from a file or from a request needs to validate the record shape; that validation is not done here, and a function that checks the record shape is outside the scope of this lesson.

When a record is missing a key, extracting the column and reading the key from each record one by one are heard through different channels.

```php
<?php
// missingcolumn.php — array_column silently skips a missing key
require 'channel.php';

$records = [
    ['name' => 'Ada', 'surname' => 'Yilmaz'],
    ['name' => 'Can'],
    ['name' => 'Efe', 'surname' => 'Kaya'],
];

$observations = [
    'array_column'    => fn() => array_column($records, 'surname'),
    'direct read'     => fn() => array_map(fn($record) => $record['surname'], $records),
    'coalescing read' => fn() => array_map(fn($record) => $record['surname'] ?? '-', $records),
];

foreach ($observations as $name => $observe) {
    [$channel, $evidence] = listen($observe);
    printf("%-17s%-9s%s\n", $name, $channel, $evidence);
}

echo 'column length: ', count(array_column($records, 'surname')), "\n";
echo 'record count: ', count($records), "\n";
```

```
array_column     silent   result array(2)
direct read      warning  E_WARNING, result array(3)
coalescing read  silent   result array(3)
column length: 2
record count: 3
```

`array_column` silently skipped the record with the missing key: the column carries two elements instead of three, and it belongs to the **silent** channel. The direct read gave `E_WARNING` on the same record and belongs to the **warning** channel; the read with `??` supplied the fallback value and went silent. The three spellings reacted to the same absence in three different ways. The point to watch is this: the result of `array_column` is no longer the same length as the list of records, so code that pairs the two by index silently pairs the wrong record.

## Destructuring and Recursive Merging

The destructuring syntax, which distributes parts of a nested array into variables, uses its own channel when the path is missing as well. Merging two nested arrays, on the other hand, is done with functions different from flat merging.

```php
<?php
// unpacking.php — destructuring and recursive merging
require 'channel.php';

$observations = [
    'full unpacking'   => fn() => (function () { [$x, $y] = [1, 2]; return $x + $y; })(),
    'missing index'    => fn() => (function () { [$x, $y] = [1]; return $y; })(),
    'missing key'      => fn() => (function () { ['name' => $name, 'age' => $age] = ['name' => 'Ada']; return $age; })(),
    'nested unpacking' => fn() => (function () { [[$p, $q], [$r]] = [[1, 2], [3]]; return $p + $q + $r; })(),
    'missing nested'   => fn() => (function () { [[$p], [$r]] = [[1]]; return $r; })(),
];

foreach ($observations as $name => $observe) {
    [$channel, $evidence] = listen($observe);
    printf("%-17s%-9s%s\n", $name, $channel, $evidence);
}

$left = ['a' => ['x' => 1, 'y' => 2], 'b' => 3];
$right = ['a' => ['y' => 20, 'z' => 30], 'b' => 4];
echo 'array_merge: ', json_encode(array_merge($left, $right)), "\n";
echo 'array_replace: ', json_encode(array_replace($left, $right)), "\n";
echo 'array_replace_recursive: ', json_encode(array_replace_recursive($left, $right)), "\n";
echo 'array_merge_recursive: ', json_encode(array_merge_recursive($left, $right)), "\n";
```

```
full unpacking   silent   result int 3
missing index    warning  E_WARNING, result null
missing key      warning  E_WARNING, result null
nested unpacking silent   result int 6
missing nested   warning  E_WARNING, result null
array_merge: {"a":{"y":20,"z":30},"b":4}
array_replace: {"a":{"y":20,"z":30},"b":4}
array_replace_recursive: {"a":{"x":1,"y":20,"z":30},"b":4}
array_merge_recursive: {"a":{"x":1,"y":[2,20],"z":30},"b":[3,4]}
```

Destructuring gives `E_WARNING` on a missing index or a missing key and leaves the variable `null`; the warning channel of reading directly by index therefore applies to destructuring as well. Fully matching and nested destructuring are silent.

In recursive merging, four functions give three distinct results. `array_merge` and `array_replace` replace the inner arrays as a whole, and the `x` key is lost. `array_replace_recursive` descends into the inner arrays and keeps `x`. `array_merge_recursive` turns colliding scalar values into arrays: `[2,20]` for `y` and `[3,4]` for `b`. The silent change in the type of the total value is a detail that can later break code that assumes `b` is a number.

## The Bounding Measurement

Reading from a missing path gave a warning. Writing to the same missing path is a separate channel.

```php
<?php
// writing.php — writing to a missing path and reading are separate channels
require 'channel.php';

$observations = [
    'deep write to empty' => function () {
        $a = [];
        $a['x']['y'] = 1;
        return $a;
    },
    'read of same path' => function () {
        $a = [];
        return $a['x']['y'];
    },
    'write over a scalar' => function () {
        $a = ['x' => 5];
        $a['x']['y'] = 1;
        return $a;
    },
];

foreach ($observations as $name => $observe) {
    [$channel, $evidence] = listen($observe);
    printf("%-24s%-9s%s\n", $name, $channel, $evidence);
}

$a = [];
$a['x']['y'] = 1;
echo 'built structure: ', json_encode($a), "\n";
```

```
deep write to empty     silent   result array(1)
read of same path       warning  E_WARNING, result null
write over a scalar     halts    Error
built structure: {"x":{"y":1}}
```

Writing `$a['x']['y'] = 1` into an empty array builds the inner array automatically and reports no level: the channel is **silent**. Reading the same path gives a warning. Reading and writing are separate channels; a problem heard in one is not heard in the other. The third line draws the limit of the distinction: if the existing value on the path is a scalar, the write no longer builds automatically and halts.

**AS9.** The silence of writing is designed as a convenience, and on a misspelled key it builds a new branch: `$a['gradez']['math'] = 90` raises no error and opens a new `gradez` branch next to the real one. This lesson does not trace who later reads that branch; all that is measured is that, at the time of writing, nothing is heard through any channel.

## Summary

- A multidimensional array is not a separate type but an array whose elements are arrays; access is the application of keys in sequence along a path.
- `count` counts only the outer level; `COUNT_RECURSIVE` counts inner arrays both as elements and through their contents.
- A missing inner key returns `null` and reports `E_WARNING`; the measuring channel of this lesson is **warning** (`missing inner key`).
- Array access on `null` also gives `E_WARNING` and returns `null`; every link after the break quietly produces `null`.
- `??` silences the whole path; it covers a misspelled path with the same silence.
- Reading and writing are separate channels: reading a missing path gives a warning, and writing deeply into an empty array builds the inner array silently.

## Next Step

This lesson measured the break of a path as `null` and a warning. In the Array Functions lesson the channel changes: a search that finds nothing raises no level, and a value is returned instead; and that value can be confused, in a loose comparison, with the return for the first element found.
