This task can certainly be done concisely with one loop.
Use id column values as temporary first level keys. This makes identifying duplicates most efficient and easy.
While iterating:
if a row's id is new to the result array, then push the whole row (in its original, flat structure) into the result array; or
if a row's id was encountered before, then cast the stored row's value element as an array before pushing the current row's value into that subarray.
If you do not want your result to have id values as the first level keys, then call array_values() to re-index the result.
The special action done implemented below is that when casting a scalar or null data type to an array, the value becomes the lone element of the newly formed array. If an array is explicitly cast as an array, then there is no effect on the data structure at all. This is why (array) can be unconditionally applied in the else branch.
Code: (Demo)
foreach ($array as $row) {
if (!isset($result[$row['id']])) {
$result[$row['id']] = $row;
} else {
$result[$row['id']]['value'] = array_merge(
(array) $result[$row['id']]['value'],
[$row['value']]
);
}
}
var_export(array_values($result));
An alternative without array_merge(): (Demo)
foreach ($array as $row) {
if (!isset($result[$row['id']])) {
$result[$row['id']] = $row;
} else {
$result[$row['id']]['value'] = (array) $result[$row['id']]['value'];
$result[$row['id']]['value'][] = $row['value'];
}
}
var_export(array_values($result));
An alternative with array_reduce(): (Demo)
var_export(
array_values(
array_reduce(
$array,
function($result, $row) {
if (!isset($result[$row['id']])) {
$result[$row['id']] = $row;
} else {
$result[$row['id']]['value'] = (array) $result[$row['id']]['value'];
$result[$row['id']]['value'][] = $row['value'];
}
return $result;
}
)
)
);