HTML Details and Summary: Accordion Without JavaScript (Tested)
Two HTML elements give you a keyboard-friendly accordion with no JavaScript. Open one at a time with name, swap the arrow for +/−, animate it, and fix the screen reader trap.
To sort a multidimensional array by value in PHP, use usort() with the spaceship operator: usort($rows, fn($a, $b) => $a['grade'] <=> $b['grade']);. That sorts a list of rows by the grade key from lowest to highest. Swap $a and $b on the right-hand side for descending order. To sort by several columns, compare arrays: [$b['grade'], $a['name']] <=> [$a['grade'], $b['name']] sorts by grade (high to low) and then by name (A to Z). usort() sorts the array in place and renumbers the keys, so use uasort() if your outer keys are IDs you need to keep. For a quick one-column sort, array_multisort(array_column($rows, 'grade'), SORT_DESC, $rows) does the same job.
I ran every example in this guide on PHP 8.4.26 on 7 October 2026, with full error reporting switched on, and pasted the real output under each one. The sample data is a small list of students with names, grades, UK-format join dates and cities. It's deliberately awkward: two students share a grade, one name starts with a lower-case letter and one starts with "Ö". Those are the cases where simple sorting code goes wrong.
Every example loads this file. The names() helper just prints the names in their current order, so the output stays short.
<?php
$students = [
['name' => 'Priya', 'grade' => 72, 'joined' => '14/09/2025', 'city' => 'Leeds'],
['name' => 'Tom', 'grade' => 65, 'joined' => '02/10/2024', 'city' => 'Cardiff'],
['name' => 'Amira', 'grade' => 72, 'joined' => '03/01/2026', 'city' => 'Belfast'],
['name' => 'Ölsen', 'grade' => 58, 'joined' => '21/02/2025', 'city' => 'Leeds'],
['name' => 'callum', 'grade' => 81, 'joined' => '30/06/2025', 'city' => 'Glasgow'],
];
function names(array $rows): string {
return implode(', ', array_map(fn($r) => $r['name'], $rows));
}
A "multidimensional array" in PHP usually means exactly this: a list (the outer array) of associative arrays (the rows). It's what you get from PDOStatement::fetchAll(PDO::FETCH_ASSOC), from json_decode($json, true) and from reading a CSV with headers. PHP's built-in sort() doesn't know which key inside each row to look at, which is why you need a comparison function.
usort() takes the array (by reference) and a callback. PHP calls the callback with two rows, $a and $b, and the callback returns a negative number if $a should come first, a positive number if $b should come first, and zero if they're equal. The spaceship operator <=> returns exactly those three values, so one arrow function is all you need.
<?php
require __DIR__ . '/data.php';
$byGrade = $students;
usort($byGrade, fn($a, $b) => $a['grade'] <=> $b['grade']);
echo "grade asc: ", names($byGrade), "\n";
usort($byGrade, fn($a, $b) => $b['grade'] <=> $a['grade']);
echo "grade desc: ", names($byGrade), "\n";
echo "keys after usort: ", implode(',', array_keys($byGrade)), "\n";
grade asc: Ölsen, Tom, Priya, Amira, callum
grade desc: callum, Priya, Amira, Tom, Ölsen
keys after usort: 0,1,2,3,4
$a['grade'] <=> $b['grade'] puts Ölsen (58) first and callum (81) last.$b['grade'] <=> $a['grade'], with $a and $b swapped, reverses it. You don't need array_reverse() afterwards, and you shouldn't use it, because it also reverses the order of ties.usort(), the keys are 0, 1, 2, 3, 4 in the new order. That's fine for a list, but it loses any meaningful keys, as I show later.usort() returns true and changes the array you pass in. It doesn't return the sorted array, which catches a lot of people out (see the mistakes section). If you need to keep the original order too, copy the array first: in PHP, $copy = $rows; makes a real copy of an array, so sorting $copy leaves $rows alone.
Priya and Amira both have 72. Here's a descending sort by grade only:
<?php
require __DIR__ . '/data.php';
// Priya and Amira both have 72. PHP 8+ sorts are stable, so Priya (first in input) stays first.
usort($students, fn($a, $b) => $b['grade'] <=> $a['grade']);
echo names($students), "\n";
callum, Priya, Amira, Tom, Ölsen
Priya comes before Amira because she's before Amira in the original data. Since PHP 8.0, every PHP sort is stable: rows that compare as equal keep their existing order. Before PHP 8.0 the order of ties was undefined, so old code that "happened to work" might not have been guaranteed to. Stability is useful, because you can sort by a secondary column first and the primary column second, but it's clearer to say what you mean with a multi-column comparison.
To sort by grade and then by name when grades are equal, compare two-element arrays. PHP compares arrays element by element, so the second value only matters when the first ones are equal.
<?php
require __DIR__ . '/data.php';
// grade high-to-low, then name A-Z
usort($students, fn($a, $b) =>
[$b['grade'], $a['name']] <=> [$a['grade'], $b['name']]);
foreach ($students as $s) {
echo mb_str_pad($s["name"], 8), $s["grade"], "\n";
}
callum 81
Amira 72
Priya 72
Tom 65
Ölsen 58
callum is top with 81. Amira and Priya are tied on 72, and now Amira comes first because of the name tie-break. The trick for mixing directions is where you put $a and $b: in the first position $b is on the left (grade descending), and in the second position $a is on the left (name ascending). You can extend the arrays to three or four columns the same way.
I use mb_str_pad() here (new in PHP 8.3) rather than str_pad() or printf("%-8s"), because "Ölsen" is six bytes but five characters, and byte-based padding would knock the column out of line. It's a small thing, but it's the first sign that text with accents needs extra care when sorting too.
If you sort in several places, a small helper keeps the callbacks out of your templates. This one takes the column names and directions as an array, compares strings case-insensitively and numbers numerically, and treats missing keys as null instead of raising a warning.
<?php
require __DIR__ . '/data.php';
/**
* Sort a list of rows by one or more columns.
* $spec: ['grade' => 'desc', 'name' => 'asc']
*/
function sortBy(array $rows, array $spec): array
{
usort($rows, function (array $a, array $b) use ($spec): int {
foreach ($spec as $col => $dir) {
$cmp = is_string($a[$col] ?? null)
? strcasecmp($a[$col], $b[$col] ?? '')
: ($a[$col] ?? null) <=> ($b[$col] ?? null);
if ($cmp !== 0) {
return strtolower($dir) === 'desc' ? -$cmp : $cmp;
}
}
return 0;
});
return $rows;
}
echo names(sortBy($students, ['city' => 'asc', 'grade' => 'desc'])), "\n";
echo names(sortBy($students, ['grade' => 'desc', 'name' => 'asc'])), "\n";
echo "original untouched: ", names($students), "\n";
Amira, Tom, callum, Priya, Ölsen
callum, Amira, Priya, Tom, Ölsen
original untouched: Priya, Tom, Amira, Ölsen, callum
The first call sorts by city A to Z, then grade high to low within each city, so the two Leeds students appear with Priya (72) before Ölsen (58). The second call reproduces the grade-then-name sort. Because the function receives its own copy of the array and returns the sorted copy, the original order is untouched, as the last line shows. That makes the helper safe to call on data you'll use again, such as rows from a database query. If those rows come from user-chosen sort options, never put the column name straight into SQL; check it against a fixed list, as I explain in my guide to PDO prepared statements.
Sorting by a string column looks easy, but the default comparisons work on bytes, not on the alphabet as people expect it.
<?php
require __DIR__ . '/data.php';
$a = $students;
usort($a, fn($x, $y) => strcmp($x['name'], $y['name']));
echo "strcmp: ", names($a), "\n";
usort($a, fn($x, $y) => strcasecmp($x['name'], $y['name']));
echo "strcasecmp: ", names($a), "\n";
$files = ['img12.png', 'img10.png', 'img2.png'];
sort($files);
echo "sort: ", implode(', ', $files), "\n";
natsort($files);
echo "natsort: ", implode(', ', $files), "\n";
if (class_exists('Collator')) {
$c = new Collator('en_GB');
usort($a, fn($x, $y) => $c->compare($x['name'], $y['name']));
echo "Collator en_GB: ", names($a), "\n";
}
strcmp: Amira, Priya, Tom, callum, Ölsen
strcasecmp: Amira, callum, Priya, Tom, Ölsen
sort: img10.png, img12.png, img2.png
natsort: img2.png, img10.png, img12.png
Collator en_GB: Amira, callum, Ölsen, Priya, Tom
strcmp() compares bytes, and every capital letter sorts before every lower-case letter. So "Tom" comes before "callum". The spaceship operator on strings does the same.strcasecmp() ignores case for plain English letters, so callum moves to second place. But "Ölsen" is still last, because "Ö" is a multi-byte character whose first byte is higher than any English letter.natsort() (and strnatcmp() / strnatcasecmp() inside usort()) sort numbers inside strings by value, so img2.png comes before img10.png. A plain sort() put img10 and img12 first. Use natural order for file names, version numbers and anything like "Room 9" and "Room 10".Collator('en_GB') from PHP's intl extension sorts the way a UK reader expects: case doesn't matter and "Ölsen" sits with the O names, between callum and Priya. This is the right choice for names, towns and anything shown to people. You need the intl extension (on Debian and Ubuntu, the php8.4-intl package), which I had to install for this test.If you're sorting the same data in the browser as well, JavaScript has the same problem and the same fix, Intl.Collator('en-GB'), which I cover in my post on how to sort an array of objects by property in JavaScript.
Dates stored as UK strings are a classic trap, because sorting them as text compares the day first.
<?php
require __DIR__ . '/data.php';
$wrong = $students;
usort($wrong, fn($a, $b) => strcmp($a['joined'], $b['joined']));
echo "as strings (wrong): ", implode(', ', array_column($wrong, 'joined')), "\n";
$toTime = fn(string $d) => DateTimeImmutable::createFromFormat('!d/m/Y', $d, new DateTimeZone('Europe/London'));
usort($students, fn($a, $b) => $toTime($b['joined']) <=> $toTime($a['joined']));
echo "newest first: ", implode(', ', array_column($students, 'joined')), "\n";
as strings (wrong): 02/10/2024, 03/01/2026, 14/09/2025, 21/02/2025, 30/06/2025
newest first: 03/01/2026, 14/09/2025, 30/06/2025, 21/02/2025, 02/10/2024
As strings, 02/10/2024 came first and 30/06/2025 last, which is just the day numbers in order: 02, 03, 14, 21, 30. That's nonsense as a date order. The fix is to turn each string into a DateTimeImmutable with createFromFormat('!d/m/Y', ...) and compare those objects, which the spaceship operator does properly. The ! resets the time to midnight, so two dates on the same day compare as equal instead of depending on the current time. With $b on the left, the newest join date comes first.
On large arrays, convert each date once before sorting rather than inside the callback, because usort() calls the callback many times per row. Better still, store dates as Y-m-d (ISO 8601) or as DATE columns in the database: ISO strings sort correctly as plain text, and the database can sort them for you with ORDER BY. For the time zone side of UK dates, including British Summer Time, see my guide to setting the PHP time zone to Europe/London.
When the keys mean something (a product name, a user ID), usort() throws them away. PHP has a sort function for each situation:
<?php
$prices = ['tea' => 3.20, 'coffee' => 2.80, 'scone' => 2.95];
$v = $prices; asort($v); echo "asort: ", json_encode($v), "\n";
$v = $prices; arsort($v); echo "arsort: ", json_encode($v), "\n";
$v = $prices; ksort($v); echo "ksort: ", json_encode($v), "\n";
$v = $prices; usort($v, fn($a, $b) => $a <=> $b); echo "usort: ", json_encode($v), "\n";
$byId = [
'u17' => ['name' => 'Priya', 'grade' => 72],
'u03' => ['name' => 'Tom', 'grade' => 65],
'u42' => ['name' => 'Callum','grade' => 81],
];
uasort($byId, fn($a, $b) => $b['grade'] <=> $a['grade']);
echo "uasort: ", implode(', ', array_keys($byId)), "\n";
asort: {"coffee":2.8,"scone":2.95,"tea":3.2}
arsort: {"tea":3.2,"scone":2.95,"coffee":2.8}
ksort: {"coffee":2.8,"scone":2.95,"tea":3.2}
usort: [2.8,2.95,3.2]
uasort: u42, u17, u03
asort() sorts by value, low to high, and keeps the keys.arsort() does the same, high to low.ksort() sorts by key, so here alphabetically by product name. krsort() reverses it.usort() sorted the values but replaced the keys with 0, 1, 2. The product names are gone, so the JSON became a plain list.uasort() is usort() that keeps keys. The students keyed by ID were sorted by grade, and the IDs u42, u17 and u03 survived. This is the answer to "php sort array by value preserve keys" for rows.array_multisort() sorts one or more column arrays and reorders the main array to match. With array_column() it reads almost like SQL:
<?php
require __DIR__ . '/data.php';
$grades = array_column($students, 'grade');
$names = array_column($students, 'name');
array_multisort($grades, SORT_DESC, $names, SORT_ASC, SORT_STRING | SORT_FLAG_CASE, $students);
echo names($students), "\n";
callum, Amira, Priya, Tom, Ölsen
This sorted by grade descending, then name ascending ignoring case (SORT_STRING | SORT_FLAG_CASE), and gave the same result as my usort() version. It's fast because it doesn't call a PHP function for every comparison. The downsides are that it's harder to read, it modifies the column arrays as well as the main array, and it can't do custom logic such as date parsing or collation. I use usort() by default and switch to array_multisort() only for big arrays where profiling shows the sort matters. If the data came from a database in the first place, though, the best sort is usually ORDER BY in the query, which can use an index.
usort() with <=>.uasort().ksort() or krsort(), or uksort() with your own comparison.sort() or rsort() for lists, asort() or arsort() to keep keys.array_multisort() with array_column().Collator from intl, called inside usort().natsort(), or strnatcasecmp() inside usort().All of these sort the array you pass in and return true, so none of them can be chained or used in an expression.
Modern PHP code often uses small classes instead of associative arrays, for example data transfer objects built from a database row or an API response. The same functions work; you just read properties with -> instead of keys.
<?php
final class Student
{
public function __construct(
public readonly string $name,
public readonly int $grade,
) {}
}
$list = [new Student('Priya', 72), new Student('Tom', 65), new Student('callum', 81)];
usort($list, fn(Student $a, Student $b) => $b->grade <=> $a->grade);
echo implode(', ', array_map(fn($s) => "$s->name ($s->grade)", $list)), "\n";
// Reusable comparator built once, passed by name
function byNameCi(Student $a, Student $b): int { return strcasecmp($a->name, $b->name); }
usort($list, byNameCi(...));
echo implode(', ', array_column($list, 'name')), "\n";
callum (81), Priya (72), Tom (65)
callum, Priya, Tom
The first sort orders the students by grade, high to low, with typed parameters (Student $a, Student $b) so PHP throws a clear TypeError if something else slips into the list. The second sort uses a named function passed with PHP 8.1's first-class callable syntax, byNameCi(...). That's handy when the same comparison is used in several places, because the function can be unit-tested on its own and the name documents what the sort does.
Two details are worth knowing. First, array_column() works on arrays of objects too, reading public properties, which is how the second line prints the names without a loop. Second, usort() moves the objects around but doesn't copy them, so the sorted array holds the same objects as the original. With readonly properties, as here, that can't cause surprises. With mutable objects, changing one through the sorted array also changes it everywhere else it's referenced, because objects in PHP are passed by handle, unlike arrays, which are copied on assignment.
<?php
require __DIR__ . '/data.php';
// 1. Using the return value
$result = usort($students, fn($a, $b) => $a['grade'] <=> $b['grade']);
var_dump($result);
// 2. Returning a bool instead of an int
$rows = [['n' => 3], ['n' => 1], ['n' => 2]];
try {
usort($rows, fn($a, $b) => $a['n'] > $b['n']);
} catch (Throwable $e) {
echo get_class($e), "\n";
}
echo implode(',', array_column($rows, 'n')), "\n";
// 3. Missing key
$rows = [['n' => 2], ['x' => 9], ['n' => 1]];
usort($rows, fn($a, $b) => ($a['n'] ?? PHP_INT_MAX) <=> ($b['n'] ?? PHP_INT_MAX));
echo json_encode($rows), "\n";
// 4. Numeric strings
$rows = [['v' => '10'], ['v' => '9'], ['v' => '100']];
usort($rows, fn($a, $b) => strcmp($a['v'], $b['v']));
echo "strcmp: ", implode(',', array_column($rows, 'v')), "\n";
usort($rows, fn($a, $b) => (int) $a['v'] <=> (int) $b['v']);
echo "int: ", implode(',', array_column($rows, 'v')), "\n";
bool(true)
Deprecated: usort(): Returning bool from comparison function is deprecated, return an integer less than, equal to, or greater than zero in mistakes.php on line 11
1,2,3
[{"n":1},{"n":2},{"x":9}]
strcmp: 10,100,9
int: 9,10,100
$result = usort(...) gives you bool(true), not the sorted array. The array you passed in has been sorted.$a['n'] > $b['n'] returns true or false. PHP 8 still sorts the array, but raises the deprecation notice shown above, and the behaviour isn't guaranteed. Always return an integer, ideally with <=>.n, reading $a['n'] raises an "Undefined array key" warning. Use $a['n'] ?? PHP_INT_MAX to push incomplete rows to the end, as in the example.strcmp() sorted '10', '100', '9'. Cast them with (int) or (float), or compare with <=> on numbers.DateTimeImmutable or store them as ISO dates.usort() on keyed data. It throws keys away. Use uasort().$a and $b in the comparison instead, so ties stay in a sensible order.htmlspecialchars(), as in my guide to preventing XSS with htmlspecialchars.Use usort() with a callback that compares that key: usort($rows, fn($a, $b) => $a['price'] <=> $b['price']);. The spaceship operator returns -1, 0 or 1, which is what usort() needs. The array is sorted in place and its keys are renumbered from 0. The PHP manual's usort() page has more examples, including one that sorts by last name and then first name.
For a list of rows, swap the operands: usort($rows, fn($a, $b) => $b['grade'] <=> $a['grade']);. For a simple array of values, use rsort(), or arsort() if you need to keep the keys. With array_multisort(), pass SORT_DESC after the column.
Compare arrays of values: usort($rows, fn($a, $b) => [$a['last'], $a['first']] <=> [$b['last'], $b['first']]);. PHP compares the first elements, and only looks at the second when the first are equal. To mix directions, swap $a and $b for the column you want descending.
Both sort with your own comparison function. usort() renumbers the keys 0, 1, 2 and so on, so it's for lists. uasort() keeps each value's original key, so it's for associative arrays where keys are IDs or names. In my test, uasort() kept the student IDs u42, u17 and u03, while usort() on a keyed price list lost the product names.
Convert the dates to DateTimeImmutable objects and compare them with <=>. For UK dates, use DateTimeImmutable::createFromFormat('!d/m/Y', $date). Don't compare dd/mm/yyyy strings directly, because that sorts by the day number. ISO dates (2026-10-07) do sort correctly as text.
If you're returning sorted data from an API, my PHP REST API tutorial shows how to send it back as JSON with the right status codes. If clients can choose the sort column with a query parameter, check it against a fixed allow-list before using it, exactly as you would for SQL.
// note
This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.
If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.
Two HTML elements give you a keyboard-friendly accordion with no JavaScript. Open one at a time with name, swap the arrow for +/−, animate it, and fix the screen reader trap.
Find duplicates with GROUP BY and HAVING, preview them with ROW_NUMBER(), then delete all but one with a self-join. Error 1093, NULLs, case and spaces, and a UNIQUE key.
structuredClone(obj) makes a true deep copy that keeps Dates, Maps, Sets and circular references. When spread, Object.assign and JSON.stringify break, and how to copy classes.