Laravel Collectionへの値の追加と検索|push・merge・contains・firstWhereの挙動差
Laravel の Collection は配列のラッパーですが、値を足すメソッドだけで主なものが6種類あり、そのうち4つは元のコレクションを直接書き換え、2つは新しいインスタンスを返します。この違いを知らないまま merge を書くと、実行しても値が増えません。この記事では laravel/framework 13.x のソース実装に沿って、追加系メソッドの使い分け、contains の比較の緩さ、firstWhere が null を返す条件、filter のキー保持が JSON レスポンスを壊す仕組みまでを整理します。
まとめ
- 値の追加は
pushaddputprependunshiftが破壊的(元のコレクションが変わる)、mergeconcatunionは非破壊(戻り値を受け取らないと何も起きない) mergeは文字列キーを上書きし数値キーを再採番、concatは渡した側のキーを捨てて末尾に連番で足すcontainsは値だけを渡すとin_arrayの緩い比較になり、文字列の'1'が数値の1に一致する。厳密に見るならcontainsStrict- Eloquent Collection の
containsは主キーで判定するため、同じ引数でも Support Collection と結果が変わる firstWhereにデフォルト値の引数はない。見つからなければ必ず null なので、代替値が要るときはfirstの第2引数を使うfiltersortBywhereonlyは元のキーを保持する。そのまま JSON で返すと配列ではなくオブジェクトになるためvalues()を挟む
以下、それぞれの根拠となる実装と、実務で踏みやすいパターンを見ていきます。
Collectionに値を追加する主なメソッドと使い分け
「コレクションに値を追加する」と一口に言っても、末尾に足すのか、キーを指定するのか、別のコレクションと結合するのかで使うメソッドが変わります。まず全体像を、元のコレクションを書き換えるかどうかで分けて押さえます。
破壊的メソッドと非破壊メソッドの一覧
| メソッド | 元のコレクション | 戻り値 | 追加位置とキー |
|---|---|---|---|
| push(…$values) | 書き換える | 自分自身 | 末尾・連番キー(複数可) |
| add($item) | 書き換える | 自分自身 | 末尾・連番キー(1つだけ) |
| put($key, $value) | 書き換える | 自分自身 | キー指定・既存なら上書き |
| prepend($value, $key = null) | 書き換える | 自分自身 | 先頭・キー指定も可 |
| unshift(…$values) | 書き換える | 自分自身 | 先頭・連番キー(複数可) |
| merge($items) | 変えない | 新インスタンス | 文字列キーは上書き |
| concat($source) | 変えない | 新インスタンス | 末尾・キーは捨てる |
| union($items) | 変えない | 新インスタンス | 既存キーを優先して残す |
13.x の Collection.php を見ると、上5つはいずれも $this->items を直接操作して $this を返しています。対して merge concat union は newInstance() で別のコレクションを組み立てて返すため、元の変数は一切変わりません。
push・add・put・prependの書き分け
破壊的な4メソッドの違いは、追加位置とキーの指定可否に集約されます。
$c = collect(['a', 'b']);
$c->push('c'); // $c 自体が ['a','b','c'] になる
$c->add('d'); // push と同じ結果。引数は1つだけ
$c->put('key', 'e'); // キーを指定して追加・既存キーなら上書き
$c->prepend('z'); // 先頭に追加
$merged = $c->merge(['f']); // 新しいインスタンスを受け取る
$c->merge(['g']); // 戻り値を捨てているので $c は変わらない
push は可変長引数なので $c->push('c', 'd', 'e') のように複数をまとめて足せます。add は引数が1つだけで、公式ドキュメントのコレクション章にも項目がありません。末尾追加は push に寄せておくと、複数追加へ変えたくなったときに書き換えが要りません。先頭側も同様で、1件なら prepend、複数なら unshift が対応します。
mergeとconcatのキー衝突時の挙動差
どちらも「別の配列やコレクションを合流させる」メソッドですが、キーの扱いが正反対です。merge は PHP の array_merge をそのまま使うので、文字列キーが衝突すると後から渡した値で上書きされます。
$base = collect(['product_id' => 1, 'price' => 100]);
$base->merge(['price' => 200, 'discount' => false])->all();
// ['product_id' => 1, 'price' => 200, 'discount' => false]
$base->concat(['price' => 200])->all();
// ['product_id' => 1, 'price' => 100, 0 => 200]
concat の実装は、元のコレクションを複製したうえで渡された要素を1つずつ push するだけです。キー名は参照されないため、上の例では price が上書きされず、連番キー 0 の要素として別に積まれます。
数値キーの配列同士でも差が出ます。collect([1, 2, 3])->merge([3, 4]) の結果は [1, 2, 3, 3, 4] です。array_merge は数値キーを振り直すため、値が重複していても除外されません。ここで union() を代わりに使うのは誤りです。union の実体は PHP の + 演算子で、左辺に存在するキーを優先して残す動作をします。値ではなくキーで重複を判定するため、値の重複排除には使えません。重複を消したいなら merge の後に unique() を挟みます。設定値の上書きなら merge、履歴やログのような追記なら concat、既定値を後から補うだけなら union、という選び方が実装意図と一致します。
非破壊メソッドの戻り値を捨てたときの症状
Collection 関連の不具合として典型的なのが、非破壊メソッドの戻り値を捨てているケースです。
$items = collect(['a']);
foreach ($rows as $row) {
$items->merge([$row]); // 何も起きない
}
foreach ($rows as $row) {
$items = $items->merge([$row]); // 代入すれば増える
// あるいは $items->push($row);
}
エラーも警告も出ないため、ループを抜けた後に件数が合わずに気づくことになります。merge concat union map filter sortBy pluck only はすべて新しいインスタンスを返す側なので、メソッドチェーンの途中で結果を組み立てるか、明示的に代入し直してください。逆に map の結果で元の変数を置き換えたい場合は、破壊版の transform が用意されています。
手元で挙動を確かめるなら、php artisan tinker でモデルやコレクションを対話的に操作する方法が確実です。collect([1,2,3])->merge([4]) と $c->merge([4]) のあとに $c を評価すれば、返り値と元の変数の差が1行で確認できます。
Collectionの基礎:配列との違いと2種類のCollection
Collection は Illuminate\Support\Collection クラスのインスタンスで、内部に配列を $items として保持しています。生の配列との違いは、array_map や usort のような関数呼び出しではなくメソッドチェーンで書ける点と、Eloquent のクエリ結果がそのまま Collection で返ってくる点です。
collect関数とCollectionクラスからの生成
生成方法は4通りありますが、いずれも同じコンストラクタに行き着きます。
$c = collect([1, 2, 3]);
$c = collect(['name' => 'Desk', 'price' => 100]);
$c = new Illuminate\Support\Collection([1, 2, 3]);
$c = Illuminate\Support\Collection::make([1, 2, 3]);
ヘルパ関数の collect() は内部で new Collection() を呼ぶだけなので、生成方法による性能差はありません。コンストラクタは getArrayableItems() を通り、null とスカラー値は Arr::wrap() で処理されます。そのため collect(null) は空のコレクション、collect('abc') は要素が1つのコレクションになります。
オブジェクトを渡した場合は Arr::from() が型ごとに分岐します。Arrayable Jsonable JsonSerializable Traversable を実装していればその変換結果が要素になり、いずれも実装しないプレーンなオブジェクトは (array) キャストされて公開プロパティがそのまま要素になります。配列に戻すには all()、JSON にするには toJson() を使います。
Support CollectionとEloquent Collectionの違い
User::all() のようなクエリ結果が返すのは Illuminate\Database\Eloquent\Collection で、collect() が返す Support Collection のサブクラスです。メソッドの大半は共通ですが、contains find load modelKeys などモデルを前提としたメソッドが追加・上書きされています。
この差は次章の contains で結果に直結します。もう1つ注意が要るのが戻り値の型で、Eloquent Collection の pluck は常に toBase() を通して Support Collection に降格します。map は結果にモデル以外が1つでも混じった場合にのみ降格します。「Eloquent Collection のつもりで load() を呼んだらメソッドが無い」という事態は、チェーンの途中でこれらを挟んだときに起こります。
大量のレコードを扱う場合は、全件をメモリに載せる Collection ではなく、cursor() が返す LazyCollection が選択肢になります。PHP のジェネレータで1件ずつ処理するため、同じメソッド名のまま使用メモリを抑えられます。
要素の存在確認:contains・containsStrict・doesntContain
「その値がコレクションに含まれるか」を調べる contains は、引数の数と型で内部の分岐が変わります。値を1つだけ渡した場合は in_array($key, $this->items)、つまり PHP の緩い比較になります。
型が違っても一致する緩い比較
引数の渡し方ごとの結果を並べます。キーと値のペアを渡す形は、要素が「行の配列」であることが前提です。
collect([1, 2, 3])->contains('1'); // true('1' == 1)
collect([1, 2, 3])->containsStrict('1'); // false('1' === 1 ではない)
collect(['name' => 'Desk'])->contains('Desk'); // true(値だけを渡す形)
collect([['name' => 'Desk']])->contains('name', 'Desk'); // true(行の配列に対するキー/値)
collect([['id' => 1]])->containsStrict('id', '1'); // false(=== で比較)
collect([['price' => 100]])->contains('price', '>', 50); // true
collect([1, 2, 3])->doesntContain(4); // true
collect([1, 2, 3])->contains(fn ($v) => $v > 2); // true
キーと値のペアを渡す形は、内部で data_get($item, $key) == $value を評価します。連想配列を1つ持つだけのコレクション(collect(['name' => 'Desk']))に contains('name', 'Desk') を渡すと、各要素は文字列 'Desk' なので data_get('Desk', 'name') が null になり false です。行の配列かどうかで書き分けてください。
リクエストパラメータは文字列で届くため、$request->input('id') をそのまま contains に渡すと、数値の ID を保持したコレクションに一致してしまいます。権限チェックのような分岐で使うなら containsStrict を選ぶか、比較前に (int) でキャストしてください。なお PHP 8 以降は数値と非数値文字列の比較規則が変わっており、in_array(0, ['a', 'b']) は false になります。PHP 7 系では true だった挙動なので、Laravel 12 時点の対応 PHP バージョンと動作要件を確認しながら移行する際の点検対象です。Laravel 13.x は PHP 8.3 以上を要求します。
Eloquent Collectionのcontainsにおける主キー判定
同じ contains(1) でも、Support Collection と Eloquent Collection では見ている対象が違います。Eloquent 側の実装は、引数が1つでモデルインスタンスなら $model->is($key)、それ以外なら $model->getKey() == $key で比較します。
$users = User::all();
$users->contains(1); // id が 1 のユーザーがいるか(主キーの緩い比較)
$users->contains($user); // is() で同一モデルか(主キー+テーブル+接続の厳密比較)
collect([1, 2, 3])->contains(1); // 要素そのものが 1 かどうか
公式ドキュメントは Eloquent コレクションの章で「主キーかモデルインスタンスを受け取る」とだけ記していますが、比較の厳密さが引数によって非対称である点には触れていません。素の値を渡す側は == の緩い比較なので $users->contains('1') が id=1 のモデルに一致し、モデルを渡す側の is() は主キー・テーブル名・データベース接続名の3つをすべて === で比較します。別テーブルの同じ ID を持つモデルを渡しても false になるのはこのためです。「Support Collection で書いたテストは通るのに本番のクエリ結果では結果が違う」という形で表面化します。
条件に一致する要素の取得:firstWhere・first・where
firstWhere は「条件に一致する最初の1件」を返すメソッドで、実装は first() に条件クロージャを渡しているだけです。where() が filter() に同じクロージャを渡してコレクションを返すのに対し、firstWhere() は要素そのものを返します。
firstWhereにデフォルト値引数が無い場合の代替
未ヒット時の戻り値を制御できるかどうかが、first との実務上の分かれ目です。
$users = collect([
['name' => 'Sato', 'age' => 30, 'admin' => false],
['name' => 'Suzuki', 'age' => 40, 'admin' => true],
]);
$users->firstWhere('age', 40); // ['name' => 'Suzuki', ...]
$users->firstWhere('age', '>', 35); // 演算子を第2引数に挟める
$users->firstWhere('age', 99); // null
$users->first(fn ($u) => $u['age'] === 99, ['name' => 'guest']);
// 第2引数が未ヒット時の戻り値になる
firstWhere のシグネチャは firstWhere($key, $operator = null, $value = null) で、デフォルト値を受け取る引数がありません。内部で呼ぶ first() にも default を渡していないため、未ヒット時の戻り値は常に null です。代替値が必要なら first() にクロージャと default を渡すか、firstWhere(...) ?? $default で受けます。プロパティへ直接アクセスする前に null 判定を挟まないと、そのまま「null に対するプロパティ参照」の致命的エラーになります。
また firstWhere の比較は =、つまり緩い比較です。厳密に一致させたいときは where($key, '===', $value)->first()、あるいは whereStrict($key, $value)->first() を使います。
引数1つのfirstWhereにおける真偽判定
$users->firstWhere('admin') のようにキーだけを渡した場合、内部では $value = true、$operator = '=' が補われます。「admin というキーを持つ最初の要素」ではなく「admin の値が真と評価される最初の要素」です。false や 0、空文字が入った要素はスキップされます。
キーの存在そのものを条件にしたい場合、返してほしいものが要素なのか値なのかで選ぶメソッドが変わります。要素そのものが欲しいなら first(fn ($item) => array_key_exists('admin', $item))、キーの値だけでよいなら value('admin') です。value() は内部で data_has() を使うため、value('author.name') のようなドット記法のネストキーにも効きます。
集計と取り出し:reduce・pluck・sortBy・mapの引数と戻り値
ここからは値を取り出して形を変える系のメソッドで、いずれも元のコレクションは変えません。
reduceコールバックの第3引数と初期値の既定
合計や連結のようにコレクション全体を1つの値へ畳み込むのが reduce です。
$total = collect(['a' => 100, 'b' => 200])
->reduce(function ($carry, $value, $key) {
return $carry + $value;
}, 0);
// 300。第2引数の 0 が初期値
collect([1, 2, 3])->reduce(fn ($carry, $v) => $carry + $v);
// 初期値を省略すると null から始まる
コールバックの引数は ($result, $value, $key) の順で、キーまで受け取れます。キー名を条件に加算対象を絞るような集計を、filter を挟まずに1つの reduce で書けます。初期値を省略すると $initial = null のまま加算が始まるので、文字列を連結する用途では明示的に空文字を渡してください。単純な合計や平均であれば sum() avg() の方が意図が伝わります。
pluck・sortBy・mapの引数順とドット記法
取り出しと並べ替えは引数の順序を取り違えやすいところです。
$posts->pluck('author.name'); // ネストした値を取り出す
$posts->pluck('title', 'id'); // 第1引数が値、第2引数がキー
$posts->sortBy('author.name'); // 並べ替えのキーにも使える
$posts->sortByDesc('published_at');
$posts->map(fn ($p) => $p->title); // 戻り値の型に注意(下記)
pluck の引数は「値、キー」の順です。逆に書くと、意図した連想配列と値が入れ替わります。sortBy は第2引数にソートフラグ(SORT_NATURAL など)、第3引数に降順フラグを取ります。sortByDesc は第3引数を持たず、単一キーなら sortBy(..., true) を呼ぶだけですが、複数キーを配列で渡した場合は各キーに SortDirection::Descending を差し込む前処理が入ります。複数キーでの並べ替えは、sortBy に配列を渡すと内部で sortByMany に振り分けられます。
map はコールバックの戻り値で新しいコレクションを組み立てます。Eloquent Collection に対して使う場合、結果にモデル以外が1つでも混じると Support Collection へ降格するため、後段で load() のような Eloquent 専用メソッドを呼ぶ設計にはできません。モデルを保ったまま加工したいなら、map の中でモデル自体を返してください。
キー保持がAPIレスポンスを壊す:values()を挟む判断基準
filter where sortBy reject は、元の要素のキーをそのまま残します。filter はコールバックを渡すと Arr::where() を経由し、その実体は array_filter($array, $callback, ARRAY_FILTER_USE_BOTH) です。array_filter はキーを保持する仕様のため、結果のキーは飛び飛びになります。
collect([1, 2, 3])->filter(fn ($v) => $v > 1)->all();
// [1 => 2, 2 => 3] ← キー 0 が欠けている
collect([1, 2, 3])->filter(fn ($v) => $v > 1)->toJson();
// {"1":2,"2":3} ← 配列ではなくオブジェクトになる
collect([1, 2, 3])->filter(fn ($v) => $v > 1)->values()->toJson();
// [2,3]
PHP の json_encode は、キーが 0 から連続する配列だけを JSON の配列として出力します。1つでも欠番があれば連想配列とみなしてオブジェクトになります。API のレスポンスで filter の結果をそのまま返すと、フロント側は配列を期待して map() を呼び、実行時エラーになります。厄介なのはここからです。先頭要素が除外されなかった場合はキーが連番のままなので、同じコードでも JSON の型が配列に変わります。データ内容によって出力型が変わるため、テストデータでは通り本番データで初めて壊れます。
判断基準は単純です。結果を JSON で外部へ返す、あるいは foreach で添字を使う場合は、filter や sortBy の直後に必ず values() を挟む。逆に、ID をキーにした連想配列として引き回している場合は values() を呼んではいけません。キーが消えて参照できなくなります。keyBy() で明示的にキーを張り直しているコードでは、後段の values() がバグになります。
Eloquent のリソース経由でレスポンスを組み立てる場合も同じで、Resource::collection() に渡す前に values() を通す運用にしておくと、コレクションの加工順を変えたときに壊れません。
よくある質問
Laravel Collectionに値を追加するとき、pushとmergeのどちらを使うべきですか。
末尾に要素を足すだけなら push です。元のコレクションを直接書き換えるため、ループの中で結果を積み上げる用途に向きます。merge は別の配列やコレクションと結合して新しいインスタンスを返すので、戻り値を必ず受け取る必要があります。既存のキーを上書きしたいなら merge、キーを無視して末尾に連結したいなら concat を選んでください。
Collectionは配列と比べて何が違うのですか。
Collection は配列を $items として保持するクラスで、filter や map をメソッドチェーンで連結できます。array_map や usort のように関数と配列を交互に書く必要がなく、処理順がそのまま読める点が実務上の差です。Eloquent のクエリ結果は最初から Collection で返るため、配列に戻す toArray() を挟まずそのまま加工できます。ただしメモリには全件が載るので、大量データは cursor() の LazyCollection に切り替えます。
containsで型が違う値が一致してしまうのはなぜですか。
値を1つだけ渡したときの contains は、内部で in_array() を第3引数なしで呼んでいるためです。PHP の in_array は既定で緩い比較のため、collect([1,2,3])->contains('1') は true になります。型まで一致させたい場合は containsStrict を使ってください。引数1つなら in_array(..., true)、キーと値のペアなら === で比較します。
firstWhereがnullを返すとき、デフォルト値を指定できますか。
firstWhere にデフォルト値の引数はないため、直接は指定できません。first() はクロージャと第2引数の default を受け取れるので、$c->first(fn ($v) => $v['age'] === 40, $default) と書き換えるか、$c->firstWhere('age', 40) ?? $default で受けます。null のままプロパティへアクセスすると致命的エラーになるため、戻り値の判定は省略できません。
reduceの初期値を省略するとどうなりますか。
シグネチャは reduce(callable $callback, $initial = null) なので、省略すると第1引数のコールバックには null が渡った状態で処理が始まります。数値の加算であれば null は 0 として扱われますが、文字列の連結や配列への追加を行う場合は意図しない結果になります。用途に応じて 0、空文字、空配列を明示的に渡してください。
filterの結果をJSONで返すと配列にならないのはなぜですか。
filter が元のキーを保持するためです。先頭要素が除外されるとキーが 1 から始まり、json_encode はキーが 0 から連続しない配列をオブジェクトとして出力します。API のレスポンスに使うなら values() でキーを振り直してください。ただし keyBy() などで意図的にキーを張っている場合は、values() がそのキーを消してしまいます。