PHPのarray_merge_recursiveで設定が配列だらけになる理由とarray_replace_recursiveの使い分け
array_merge_recursiveは同じ文字列キーの値を上書きせず配列にまとめる関数で、array_replace_recursiveは同じキーの値を再帰的に上書きする関数です。デフォルト設定にユーザー設定を重ねたい用途なら、選ぶべきは後者です。
名前から「mergeの再帰版だから、深い階層まで上書きしてくれるんだろう」と思いたくなるのですが、実際に動かすとそうならないんですよね。私も最初、設定のマージで debug が [false, true] になっていて首をかしげました。
array_merge_recursiveは何をしているのか
まず挙動を確認します。デフォルト設定とユーザー設定を重ねるだけの、よくある場面です。
<?php
$default = [
'db' => ['host' => 'localhost', 'port' => 3306],
'debug' => false,
'hosts' => ['a', 'b'],
];
$user = [
'db' => ['host' => 'example.com'],
'debug' => true,
'hosts' => ['c'],
];
echo json_encode(array_merge_recursive($default, $user));
// {"db":{"host":["localhost","example.com"],"port":3306},"debug":[false,true],"hosts":["a","b","c"]}
文字列キーが衝突すると、後の値で上書きするのではなく、両方を配列に並べます。host が配列になり、debug も [false, true] になっています。設定値として使うには、まず意図と違う形です。
一方で、数値キーの配列(hosts)は連番で振り直されて連結されます。リストを足し合わせたいときにはこれが効きますが、設定の上書きとは目的が別物ですね。
上書きしたいならarray_replace_recursiveを使う
同じ構造で array_replace_recursive を使うと、期待どおりの上書きになります。「なぜ上書きの意図ならreplaceなのか」は、名前の動詞をそのまま読めば済む話でした。
<?php
echo json_encode(array_replace_recursive($default, $user));
// {"db":{"host":"example.com","port":3306},"debug":true,"hosts":["c","b"]}
db の port は残り、host だけが差し替わっています。ここは嬉しいところです。ただし hosts が [“c”,”b”] になっている点には注意が必要です。
リストはキー単位で上書きされる
array_replace_recursive は数値キーも「キー単位」で置き換えます。[‘a’,’b’] に [‘c’] を重ねると、キー0だけが c になり、キー1の b が残ります。リスト全体を差し替えたつもりでも、古い要素が混ざるわけです。
<?php
echo json_encode(array_replace_recursive(['a' => ['x', 'y']], ['a' => [1 => 'Z']]));
// {"a":["x","Z"]}
これはバグではなく仕様で、キーを指定して一部だけ差し替える使い方には向いています。設定で「リストは丸ごと置き換えたい」なら、後述の自前関数のほうが安全です。
片方が配列でないときはどうなるか
型が食い違うキーも要注意です。array_replace_recursive は、基本的に後ろの値が勝ちます。
<?php
echo json_encode(array_replace_recursive(['a' => ['x']], ['a' => 's']));
// {"a":"s"}
echo json_encode(array_replace_recursive(['db' => ['host' => 'l', 'port' => 1]], ['db' => null]));
// {"db":null}
ユーザー設定で db を null にしてしまうと、デフォルトの階層ごと消えます。一方 array_merge_recursive は、文字列と配列が衝突すると文字列を配列に包んで結合します([‘a’=>’x’] と [‘a’=>[‘y’]] で {“a”:[“x”,”y”]})。どちらも「何も起きないはず」とは言えない挙動です。
リストは丸ごと置換したいときの自前関数
実務の設定マージでは、連想配列は再帰的に重ね、リストは丸ごと置き換える、という動きが欲しいことが多いです。array_is_list(PHP 8.1以降)を使うと短く書けます。
<?php
function deepMerge(array $base, array $override): array
{
foreach ($override as $key => $value) {
if (is_array($value) && isset($base[$key]) && is_array($base[$key]) && !array_is_list($value)) {
$base[$key] = deepMerge($base[$key], $value);
} else {
$base[$key] = $value;
}
}
return $base;
}
echo json_encode(deepMerge($default, $user));
// {"db":{"host":"example.com","port":3306},"debug":true,"hosts":["c"]}
これなら hosts は [“c”] に置き換わり、db は階層ごとに上書きされます。空配列 [] を上書き値にすると array_is_list が true になるので、「空にして上書き」も意図どおり動きます。
まとめ
設定の上書きが目的なら array_replace_recursive、配列を寄せ集めたいなら array_merge_recursive、と目的で分けるのが基本かなと思います。ただリストの扱いまで気にするなら、十数行の自前関数のほうが、結局あとで悩まずに済む気がしています。
よくある質問
Q. array_mergeでは再帰的な上書きはできませんか。
A. array_merge は第1階層だけを見て、同じ文字列キーなら後の値で丸ごと置き換えます。上の例だと db が [‘host’ => ‘example.com’] だけになり、port が消えます。
Q. array_merge_recursiveが向いているのはどんな場面ですか。
A. 複数の配列から同じキーの値を全部集めたい場面です。たとえばエラーメッセージをキーごとに溜めていくような用途では、値が配列にまとまる挙動がそのまま役に立ちます。
Q. 引数に配列を3つ以上渡せますか。
A. 渡せます。どちらの関数も可変長引数で、左から順に重ねていきます。