Multidimensional Arrays
A multidimensional array is an array whose items are themselves arrays. A class register with rows of marks, a cinema seat map, a shopping order with a customer and a list of items: all of these are arrays inside arrays. Database results and decoded JSON arrive in this shape too, so you will handle nested arrays in almost every PHP project.
In Arrays you met a simple list of records. This lesson goes further: grids, deeply nested data, nested loops, changing values inside a loop, grouping and indexing records, sorting by a nested column, and walking a tree of unknown depth.
Arrays inside arrays
The simplest multidimensional array is a grid, also called a two-dimensional array. Think of a table with rows and columns. You read a value with two sets of square brackets: first the row, then the column.
<?php
declare(strict_types=1);
// Marks of 3 students (rows) in 4 subjects (columns)
$marks = [
[78, 85, 92, 66], // row 0: Aarav
[88, 91, 79, 95], // row 1: Priya
[59, 72, 81, 70], // row 2: Farhan
];
echo $marks[1][3], "\n"; // Priya's 4th subject
$marks[2][0] = 64; // correct Farhan's first mark
echo $marks[2][0], "\n";
echo count($marks), " rows, ", count($marks[0]), " columns\n";
echo 'Priya total: ', array_sum($marks[1]), "\n";95 64 3 rows, 4 columns Priya total: 353
Read $marks[1][3] from left to right: $marks[1] is Priya's whole row, an array of four numbers, and [3] picks the fourth number from that row. count($marks) counts rows only. To count every value in every row, you would add up the row counts.
Looping with nested foreach
To visit every value, put one foreach inside another. The outer loop walks the rows; the inner loop walks the values inside the current row. Keys can be letters or numbers, which makes a seat map easy to model:
<?php
declare(strict_types=1);
$hall = [
'A' => [1 => 'X', 2 => 'X', 3 => '-', 4 => '-'],
'B' => [1 => '-', 2 => 'X', 3 => '-', 4 => '-'],
'C' => [1 => '-', 2 => '-', 3 => '-', 4 => 'X'],
];
$free = 0;
foreach ($hall as $row => $seats) {
echo $row, ' ';
foreach ($seats as $number => $status) {
echo $status === 'X' ? '[X]' : "[$number]";
if ($status === '-') {
$free++;
}
}
echo "\n";
}
echo "Free seats: $free\n";A [X][X][3][4] B [1][X][3][4] C [1][2][3][X] Free seats: 8
X marks a booked seat. Booking seat B3 is a single assignment: $hall['B'][3] = 'X';. Give your loop variables meaningful names ($row, $seats, $number) so the nesting stays readable.
Nested associative arrays
Real data is rarely a neat grid. An online order has a customer, the customer has an address, and the order has a list of items. Each level can be a different shape. This is exactly how JSON from an API looks after decoding:
<?php
declare(strict_types=1);
$order = [
'id' => 'ORD-1042',
'customer' => [
'name' => 'Ananya Iyer',
'address' => ['city' => 'Kochi', 'pin' => '682001'],
],
'items' => [
['name' => 'Banana chips 500 g', 'price' => 180, 'qty' => 2],
['name' => 'Kerala coffee 250 g', 'price' => 320, 'qty' => 1],
],
];
echo $order['customer']['address']['city'], "\n";
echo $order['items'][1]['name'], "\n";
// Add one more item to the nested list
$order['items'][] = ['name' => 'Jackfruit jam', 'price' => 150, 'qty' => 3];
echo count($order['items']), " items\n";
// A key that may be missing: use ??
echo $order['customer']['phone'] ?? 'No phone given', "\n";
echo $order['customer']['address']['landmark'] ?? 'No landmark', "\n";Kochi Kerala coffee 250 g 3 items No phone given No landmark
The empty brackets in $order['items'][] = ... append to the inner list. The null coalescing operator ?? is the safe way to read deep keys that may not exist. It checks the whole chain, so even if address were missing you would get the default instead of an "Undefined array key" warning.
Changing values inside a loop
This mistake catches almost everyone. In foreach ($cart as $line), $line is a copy of the row. Changing the copy does not change the array:
<?php
declare(strict_types=1);
$cart = [
['name' => 'Kurta', 'price' => 1299, 'qty' => 1],
['name' => 'Dupatta', 'price' => 499, 'qty' => 2],
];
// Wrong: $line is a copy, so the cart does not change
foreach ($cart as $line) {
$line['price'] = intdiv($line['price'] * 90, 100);
}
echo $cart[0]['price'], "\n";
// Right: change the original through its key
foreach ($cart as $i => $line) {
$cart[$i]['price'] = intdiv($line['price'] * 90, 100);
}
echo $cart[0]['price'], ' and ', $cart[1]['price'], "\n";1299 1169 and 449
You may also see a reference loop, foreach ($cart as &$line), which makes $line point at the real row. It works, but you must write unset($line); after the loop, or a later loop that reuses $line will quietly overwrite your last row. Writing through the key, as above, avoids that trap.
Indexing and grouping records
array_column() pulls one field out of every record. Pass null as the column and a field name as the third argument, and you get the whole rows re-keyed by that field. That turns a list into a lookup table, so you can find a record by id without a loop. For grouping, build a new nested array with [$key][]:
<?php
declare(strict_types=1);
$students = [
['id' => 101, 'name' => 'Aarav', 'city' => 'Rohtak', 'marks' => 81],
['id' => 102, 'name' => 'Meera', 'city' => 'Kochi', 'marks' => 94],
['id' => 103, 'name' => 'Kabir', 'city' => 'Rohtak', 'marks' => 67],
['id' => 104, 'name' => 'Isha', 'city' => 'Pune', 'marks' => 88],
];
// One column as a plain list
echo implode(', ', array_column($students, 'name')), "\n";
// Whole rows, re-keyed by id: fast lookups
$byId = array_column($students, null, 'id');
echo $byId[103]['name'], ' from ', $byId[103]['city'], "\n";
// Group names by city
$byCity = [];
foreach ($students as $s) {
$byCity[$s['city']][] = $s['name'];
}
print_r($byCity);Aarav, Meera, Kabir, Isha
Kabir from Rohtak
Array
(
[Rohtak] => Array
(
[0] => Aarav
[1] => Kabir
)
[Kochi] => Array
(
[0] => Meera
)
[Pune] => Array
(
[0] => Isha
)
)$byCity[$s['city']][] = $s['name'] does two jobs at once: if the city key does not exist yet, PHP creates an empty list for it, then appends the name. This "group by" pattern is worth memorising.
Sorting by a nested column
usort() sorts a list of records with your own comparison, as you saw in Array Functions. The comparison can reach as deep as you need. Here hotels are sorted by their weekend price, which sits one level down:
<?php
declare(strict_types=1);
$hotels = [
['name' => 'Hawa Mahal View', 'rating' => 4.2, 'price' => ['weekday' => 2400, 'weekend' => 3100]],
['name' => 'Pink City Stay', 'rating' => 3.9, 'price' => ['weekday' => 1800, 'weekend' => 2600]],
['name' => 'Amber Courtyard', 'rating' => 4.6, 'price' => ['weekday' => 3200, 'weekend' => 3000]],
];
usort($hotels, fn(array $a, array $b): int =>
$a['price']['weekend'] <=> $b['price']['weekend']
);
foreach ($hotels as $h) {
echo "{$h['name']}: ₹{$h['price']['weekend']} on weekends\n";
}
echo json_encode($hotels[0], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), "\n";Pink City Stay: ₹2600 on weekends
Amber Courtyard: ₹3000 on weekends
Hawa Mahal View: ₹3100 on weekends
{
"name": "Pink City Stay",
"rating": 3.9,
"price": {
"weekday": 1800,
"weekend": 2600
}
}json_encode() with JSON_PRETTY_PRINT is also a handy way to look at nested data while debugging; it is easier to read than print_r() for deep arrays.
Advanced: JSON trees and recursion
Some data has no fixed depth. A category menu can have sub-categories, which have their own sub-categories, and so on. You cannot write the right number of nested loops in advance. Instead, write a recursive function: a function that calls itself for each child list, stopping when a list is empty.
<?php
declare(strict_types=1);
$json = '{
"store": "Lucknow Book Depot",
"categories": [
{"name": "School", "children": [
{"name": "Class 10", "children": []},
{"name": "Class 12", "children": [
{"name": "Science", "children": []},
{"name": "Commerce", "children": []}
]}
]},
{"name": "Novels", "children": []}
]
}';
$data = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
echo $data['categories'][0]['children'][1]['children'][0]['name'], "\n\n";
function printTree(array $nodes, int $depth = 0): void
{
foreach ($nodes as $node) {
echo str_repeat(' ', $depth), '- ', $node['name'], "\n";
printTree($node['children'], $depth + 1); // the function calls itself
}
}
echo $data['store'], "\n";
printTree($data['categories']);Science
Lucknow Book Depot
- School
- Class 10
- Class 12
- Science
- Commerce
- Novelsjson_decode() with true as the second argument returns nested arrays instead of objects, which is what this lesson uses. Each call of printTree() handles one level and passes $depth + 1 down, so the indentation grows with the depth. Recursion ends naturally when a node's children list is empty, because the foreach has nothing to loop over. You can learn more about JSON in JSON & APIs.
Common mistakes
- Changing the loop copy.
foreach ($rows as $row) { $row['x'] = 1; }changes nothing. Write to$rows[$i]['x']instead. - Forgetting
unset()after a reference loop. Afterforeach ($rows as &$row), alwaysunset($row);. - Reading deep keys without
??. A missing level gives an "Undefined array key" warning. Use$a['x']['y'] ?? 'default'. - Mixing up the order of brackets.
$marks[1][3]is row 1, column 3;$marks[3][1]is something else, or nothing at all. - Writing too many nested loops. If you need four levels of
foreach, the data is probably a tree; use recursion.
Key points
- A multidimensional array holds arrays as items; read it with one pair of brackets per level.
- Nested
foreachloops visit grids; name the loop variables clearly. ??reads deep keys safely, and[]appends to an inner list.foreachgives you copies: update rows through their key, orunset()after a reference loop.array_column($rows, null, 'id')indexes records, and$groups[$key][] = $valuegroups them.usort()can sort by a nested key; recursion handles trees of any depth.
Exercise
You have this list of orders: ids 1 to 5 from Jaipur (₹1,299), Pune (₹450), Jaipur (₹899), Kochi (₹2,200) and Pune (₹700). Build a nested array $summary keyed by city, where each city has an orders count and a total. Sort it by total, highest first, keeping the city keys, and print one line per city such as Jaipur: 2 orders, ₹2198.
Show solution
<?php
declare(strict_types=1);
$orders = [
['id' => 1, 'city' => 'Jaipur', 'amount' => 1299],
['id' => 2, 'city' => 'Pune', 'amount' => 450],
['id' => 3, 'city' => 'Jaipur', 'amount' => 899],
['id' => 4, 'city' => 'Kochi', 'amount' => 2200],
['id' => 5, 'city' => 'Pune', 'amount' => 700],
];
$summary = [];
foreach ($orders as $o) {
$city = $o['city'];
$summary[$city]['orders'] = ($summary[$city]['orders'] ?? 0) + 1;
$summary[$city]['total'] = ($summary[$city]['total'] ?? 0) + $o['amount'];
}
uasort($summary, fn(array $a, array $b): int => $b['total'] <=> $a['total']);
foreach ($summary as $city => $s) {
echo "$city: {$s['orders']} orders, ₹{$s['total']}\n";
}Kochi: 1 orders, ₹2200 Jaipur: 2 orders, ₹2198 Pune: 2 orders, ₹1150
The ?? 0 starts each counter at zero the first time a city appears. uasort() is used instead of usort() because it keeps the city names as keys.
Quick Quiz
1. Given $grid = [[1, 2], [3, 4]];, what is $grid[1][0]?
$grid[1] is the second row [3, 4], and [0] takes its first value, 3.
2. Why does foreach ($rows as $row) { $row['done'] = true; } leave $rows unchanged?
By default foreach copies each value; write to $rows[$key] or use a reference to change the original.
3. What does array_column($students, null, 'id') return?
A null column means "the whole row", and the third argument chooses the key.
4. What is the safest way to read $order['customer']['phone'] when phone may be missing?
?? checks that every level exists without a warning; ?: still warns, and @ only hides the problem.
Learn PHP with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
