Chapter 2 - Arr and Collection: the methods nobody imports
Chapter 2 continues Part I (Code Fundamentals) by extending the reader's knowledge of Arr
and Collection - two classes almost every Laravel application already relies on - with
Collection::getOrPut(), Collection::unshift(), Collection::diffUsing() and
Collection::diffKeysUsing(), Collection::mapToDictionary(), Collection::toBase(), and
Arr::arrayable(): methods that solve real, recurring problems but are absent from the
official documentation. Every example in this chapter is verified against laravel/framework
v13.22.0 and the laravel/docs 13.x branch, and is a real, green Pest test drawn from the
book's companion application.
The chapter's examples share a single running scenario: reconciling stock movements into an inventory report, so each method is seen solving a piece of the same real problem rather than a disconnected one-off.
| Method | Purpose |
|---|---|
Collection::getOrPut($key, $value) | Get a value by key, or store and return a default if the key is missing. |
Collection::unshift(...$values) | Prepend one or more items to the beginning of the collection, in order. |
Collection::diffUsing($items, callable $callback) | Diff collection values against another set using a custom comparator. |
Collection::diffKeysUsing($items, callable $callback) | Diff collection keys against another set using a custom comparator. |
Collection::mapToDictionary(callable $callback) | Group items into plain-array buckets via a callback returning one key/value pair. |
Collection::toBase() | Downgrade a derived collection (e.g. Eloquent) to a plain base Collection. |
Arr::arrayable($value) | Check whether a value can be represented as an array. |
Collection::getOrPut()
Case type: undocumented method on the documented Collection class.
Alias flag: none - it has its own existence check and lazily-evaluated default, it does not wrap another public method.
Version note: introduced in Laravel 8.x (absent before that release).
Collection::getOrPut() returns the value stored under a key, or stores and returns a default
if the key is missing yet:
public function getOrPut($key, $value)
{
if (array_key_exists($key ?? '', $this->items)) {
return $this->items[$key ?? ''];
}
$this->offsetSet($key, $value = value($value));
return $value;
}
Minimal snippet
collect(['SKU-1' => 7])->getOrPut('SKU-1', 0); // 7, unchanged
collect(['SKU-1' => 7])->getOrPut('SKU-2', 0); // 0, and now stored under 'SKU-2' too
Manual way vs. discovered way
There is no documented Laravel method for this specific job, only the manual
isset()-then-initialize pattern developers already write by hand. getOrPut() is that same
pattern, already written and named:
$manual = [];
foreach ($movements as $movement) {
if (! isset($manual[$movement['sku']])) {
$manual[$movement['sku']] = 0;
}
$manual[$movement['sku']] += $movement['quantity'];
}
$discovered = collect();
foreach ($movements as $movement) {
$discovered[$movement['sku']] = $discovered->getOrPut($movement['sku'], 0) + $movement['quantity'];
}
expect($manual)->toBe($discovered->all());
The two loops produce identical totals - the value is not new behavior, it is not having to
re-derive the existence check in every place that needs a running total. The default is also
only ever used once per key: calling getOrPut('SKU-1', 0) again once SKU-1 already holds 7
returns 7, not 0 - the default is never re-applied once a value exists.
Real scenario: a stock-reporting endpoint
The companion app's StockLedger uses getOrPut() to accumulate a running total per SKU while
walking a list of stock movements:
public function runningTotals(Collection $movements): Collection
{
$totals = collect();
foreach ($movements as $movement) {
$totals[$movement['sku']] = $totals->getOrPut($movement['sku'], 0) + $movement['quantity'];
}
return $totals;
}
A thin StockController exposes it as a reporting endpoint:
public function report(Request $request)
{
$movements = $request->validate([
'movements' => ['array'],
'movements.*.sku' => ['required', 'string'],
'movements.*.quantity' => ['required', 'integer'],
])['movements'];
$totals = app(StockLedger::class)->runningTotals(collect($movements));
return response()->json(['totals' => $totals]);
}
it('computes running totals for stock movements through the reporting endpoint', function () {
postJson('/api/stock/report', [
'movements' => [
['sku' => 'SKU-1', 'quantity' => 5],
['sku' => 'SKU-2', 'quantity' => 3],
['sku' => 'SKU-1', 'quantity' => 2],
],
])
->assertOk()
->assertJson(['totals' => ['SKU-1' => 7, 'SKU-2' => 3]]);
});
Because getOrPut() only writes its default the first time a SKU is seen, runningTotals() can
seed and accumulate in the same expression, on the same line, for every movement in the list -
without a separate initialization pass over the SKUs first.
Collection::unshift()
Case type: undocumented method on the documented Collection class.
Alias flag: none -
it is a one-line wrapper over PHP's native array_unshift(), but it is not an alias of the
documented Collection::prepend(): prepend() accepts only one value per call, unshift() is
variadic.
Version note: introduced in Laravel 11.x (absent from the 10.x branch).
Collection::unshift() prepends one or more items to the beginning of the collection, in the
order given:
public function unshift(...$values)
{
array_unshift($this->items, ...$values);
return $this;
}
Minimal snippet
collect([['sku' => 'SKU-2', 'quantity' => 3]])
->unshift(['sku' => 'SKU-1', 'quantity' => 10]);
// [['sku' => 'SKU-1', 'quantity' => 10], ['sku' => 'SKU-2', 'quantity' => 3]]
Documented way vs. discovered way
For a single value, Collection::prepend() and unshift() produce the same result. The
difference shows up with more than one value. prepend($value, $key = null) only accepts one
value per call, so inserting two items means chaining two calls - and each call jumps its value
ahead of whatever is already there, reversing the order relative to how the calls were written:
$stockTake = ['sku' => 'SKU-1', 'quantity' => 10];
$adjustment = ['sku' => 'SKU-2', 'quantity' => -2];
$movements = [['sku' => 'SKU-3', 'quantity' => 3]];
collect($movements)->prepend($stockTake)->prepend($adjustment);
// [$adjustment, $stockTake, ['sku' => 'SKU-3', 'quantity' => 3]] - reversed
collect($movements)->unshift($stockTake, $adjustment);
// [$stockTake, $adjustment, ['sku' => 'SKU-3', 'quantity' => 3]] - as written
unshift() takes both values in a single call and keeps them in the order passed - there is no
call-order arithmetic to do.
Real scenario: opening balances on the stock-reporting endpoint
The companion app's StockLedger uses unshift() to prepend however many opening-balance
adjustments a report needs, in one call:
public function withOpeningBalances(Collection $movements, array ...$openingBalances): Collection
{
return $movements->unshift(...$openingBalances);
}
StockController::report() accepts them as an optional array and applies them before computing
totals:
if (! empty($validated['opening_balances'])) {
$movements = $ledger->withOpeningBalances($movements, ...$validated['opening_balances']);
}
$totals = $ledger->runningTotals($movements);
it('includes opening balances in the running totals through the reporting endpoint', function () {
postJson('/api/stock/report', [
'movements' => [
['sku' => 'SKU-1', 'quantity' => 5],
],
'opening_balances' => [
['sku' => 'SKU-1', 'quantity' => 10],
['sku' => 'SKU-2', 'quantity' => 4],
],
])
->assertOk()
->assertJson(['totals' => ['SKU-1' => 15, 'SKU-2' => 4]]);
});
Because unshift() takes the whole list of adjustments in one call, report() can pass through
however many a client sends without reasoning about the order they will end up in - a guarantee
chained prepend() calls alone could not make.
Collection::diffUsing() and Collection::diffKeysUsing()
Case type: undocumented methods on the documented Collection class.
Alias flag: none
for either - both are thin wrappers over PHP's native array_udiff()/array_diff_ukey(), but
neither is an alias of the documented Collection::diffAssocUsing() sibling, which compares
keys and values together rather than one or the other.
Version note: both introduced in
Laravel 5.6, alongside diffAssocUsing().
public function diffUsing($items, callable $callback)
{
return $this->newInstance(array_udiff($this->items, $this->getArrayableItems($items), $callback));
}
public function diffKeysUsing($items, callable $callback)
{
return $this->newInstance(array_diff_ukey($this->items, $this->getArrayableItems($items), $callback));
}
Minimal snippet
collect([['sku' => 'SKU-1'], ['sku' => 'SKU-2']])
->diffUsing([['sku' => 'sku-1']], fn ($a, $b) => strcasecmp($a['sku'], $b['sku']));
// [['sku' => 'SKU-2']] - 'SKU-1' matched 'sku-1' case-insensitively, so it is excluded
collect(['SKU-1' => 10, 'SKU-2' => 5])->diffKeysUsing(['sku-1' => 12], fn ($a, $b) => strcasecmp($a, $b));
// ['SKU-2' => 5]
Manual way vs. discovered way
There is no documented Laravel method for a custom-comparator diff, only a manual loop that
checks every candidate by hand. diffUsing() is that same loop, already written:
$manual = array_values(array_filter($before->all(), function ($item) use ($after) {
foreach ($after->all() as $candidate) {
if (strcasecmp($item['sku'], $candidate['sku']) === 0) {
return false;
}
}
return true;
}));
$discovered = $before->diffUsing($after, fn ($a, $b) => strcasecmp($a['sku'], $b['sku']))->values()->all();
expect($manual)->toBe($discovered);
Both methods type their callback as returning an int in their own docblocks - the same
usort-style contract PHP's comparison functions expect: negative, zero, or positive, never a
boolean. This is not a formality. Swap the comparator above for one that looks equivalent but
returns a boolean, on the same data:
$before->diffUsing($after, fn ($a, $b) => strcasecmp($a['sku'], $b['sku']))->values()->all();
// [['sku' => 'SKU-2', 'quantity' => 5]] - correct
$before->diffUsing($after, fn ($a, $b) => strcasecmp($a['sku'], $b['sku']) !== 0)->values()->all();
// all three records - wrong, not a partial miss
array_udiff() sorts internally using the callback to line elements up efficiently; a boolean
comparator can only say "equal" or "not", never "less than" or "greater than", so the sort it
depends on breaks - and it breaks completely, not gracefully.
Real scenario: reconciling two stock snapshots
The companion app's StockLedger uses both methods together to reconcile a before/after pair
of stock snapshots:
public function missingSnapshotRecords(Collection $before, Collection $after): Collection
{
return $before->diffUsing($after, fn ($a, $b) => strcasecmp($a['sku'], $b['sku']));
}
public function missingSkuKeys(Collection $beforeTotals, Collection $afterTotals): Collection
{
return $beforeTotals->diffKeysUsing($afterTotals, fn ($a, $b) => strcasecmp($a, $b));
}
missingSkuKeys() reuses runningTotals() from earlier in this chapter, so the two SKU-keyed
totals being compared come from the same aggregation already in place:
it('reconciles two stock snapshots through the endpoint', function () {
postJson('/api/stock/reconcile', [
'before' => [
['sku' => 'SKU-1', 'quantity' => 10],
['sku' => 'SKU-2', 'quantity' => 5],
['sku' => 'SKU-3', 'quantity' => 2],
],
'after' => [
['sku' => 'sku-1', 'quantity' => 12],
['sku' => 'SKU-3', 'quantity' => 2],
],
])
->assertOk()
->assertJson([
'missing_records' => [['sku' => 'SKU-2', 'quantity' => 5]],
'missing_skus' => ['SKU-2'],
]);
});
Both answers agree because they describe the same underlying gap - SKU-2 disappeared between
the two snapshots - one at the level of raw records, the other at the level of aggregated
totals.
Collection::mapToDictionary()
Case type: undocumented method on the documented Collection class.
Alias flag: none -
it has its own accumulation logic; groupBy() is actually built on top of mapToDictionary()
internally, not the other way round.
Version note: introduced in Laravel 5.5 (landed under
the working name buildToDictionary, renamed before release).
public function mapToDictionary(callable $callback)
{
$dictionary = [];
foreach ($this->items as $key => $item) {
$pair = $callback($item, $key);
$key = key($pair);
$value = reset($pair);
if (! isset($dictionary[$key])) {
$dictionary[$key] = [];
}
$dictionary[$key][] = $value;
}
return $this->newInstance($dictionary);
}
Minimal snippet
collect([['sku' => 'SKU-1', 'warehouse' => 'north']])
->mapToDictionary(fn ($movement) => [$movement['warehouse'] => $movement]);
// ['north' => [['sku' => 'SKU-1', 'warehouse' => 'north']]]
Documented way vs. discovered way
groupBy() solves a similar grouping problem, but wraps each bucket in another Collection.
mapToDictionary() builds plain arrays instead - the callback returns one [key => value] pair
per item, and the method appends the value into that key's array bucket:
$documented = $movements->groupBy('warehouse');
$discovered = $movements->mapToDictionary(fn ($movement) => [$movement['warehouse'] => $movement]);
expect($documented->get('north'))->toBeInstanceOf(Collection::class)
->and($discovered->get('north'))->toBeArray()
->and($documented->map(fn ($group) => $group->all())->all())->toBe($discovered->all());
The grouped content is identical either way - only the bucket type differs: a Collection you
can chain further collection methods on, or a plain array when you do not need that.
Real scenario: grouping stock movements by warehouse
public function groupByWarehouse(Collection $movements): Collection
{
return $movements->mapToDictionary(fn ($movement) => [$movement['warehouse'] => $movement]);
}
it('groups movements by warehouse through the reporting endpoint', function () {
postJson('/api/stock/by-warehouse', [
'movements' => [
['sku' => 'SKU-1', 'quantity' => 10, 'warehouse' => 'north'],
['sku' => 'SKU-2', 'quantity' => 5, 'warehouse' => 'south'],
['sku' => 'SKU-3', 'quantity' => 2, 'warehouse' => 'north'],
],
])
->assertOk()
->assertJson([
'by_warehouse' => [
'north' => [
['sku' => 'SKU-1', 'quantity' => 10, 'warehouse' => 'north'],
['sku' => 'SKU-3', 'quantity' => 2, 'warehouse' => 'north'],
],
'south' => [
['sku' => 'SKU-2', 'quantity' => 5, 'warehouse' => 'south'],
],
],
]);
});
Because the JSON response only needs to serialize the grouped movements, not call further
collection methods on each group, the plain-array buckets mapToDictionary() produces are
exactly what byWarehouse() needs - no wrapping Collection to strip away first.
Collection::toBase()
Case type: undocumented method on the documented Collection class.
Alias flag: none - it has its own logic, not a wrapper around another public method.
Version note: introduced in Laravel 5.3.
public function toBase()
{
return new self($this);
}
Because this is written as new self($this), not new static($this), self resolves at
compile time to Illuminate\Support\Collection - the class where the method is textually
defined - regardless of which subclass instance calls it. Illuminate\Database\Eloquent\ Collection extends the base Collection and never overrides toBase(), so calling it on an
Eloquent collection always downgrades to the plain base collection.
Minimal snippet
$movements = StockMovement::all(); // Illuminate\Database\Eloquent\Collection
get_class($movements->toBase()); // 'Illuminate\Support\Collection'
Documented way vs. discovered way
The "documented way" here is simply continuing to use the Eloquent collection - which works
until a method your code relies on behaves differently there. unique() (no key) is exactly
such a method: the Eloquent version dedupes by primary key, the base version compares full
values:
$movements->unique()->pluck('quantity')->all();
// [99] - Eloquent's unique() dedupes by id, keeping only the last one
$movements->toBase()->unique()->pluck('quantity')->all();
// [10, 99] - the base unique() compares full values, both kept
Real scenario: an audit that must not hide a pending edit
StockLedger::distinctSnapshots() converts an Eloquent collection of StockMovement models -
possibly including an uncommitted in-memory edit sharing the same id as a saved row - back to
a base collection before deduplicating, so the pending edit is not silently collapsed into the
saved row:
public function distinctSnapshots(EloquentCollection $movements): Collection
{
return $movements->toBase()->unique();
}
it('confirms toBase() changes unique() semantics for models sharing the same id', function () {
$saved = StockMovement::factory()->create(['sku' => 'SKU-1', 'quantity' => 10, 'warehouse' => null]);
$pending = clone $saved;
$pending->quantity = 99;
$movements = new EloquentCollection([$saved, $pending]);
expect($movements->unique()->count())->toBe(1)
->and(app(StockLedger::class)->distinctSnapshots($movements)->count())->toBe(2);
});
An audit endpoint built on distinctSnapshots() shows both the saved quantity and the pending
one, instead of the identity-based unique() masking the difference.
Arr::arrayable()
Case type: undocumented method on the documented Arr class.
Alias flag: none.
Version note: introduced in Laravel 12.x, alongside the documented Arr::from() - which
never itself mentions arrayable().
public static function arrayable($value)
{
return is_array($value)
|| $value instanceof Arrayable
|| $value instanceof Traversable
|| $value instanceof Jsonable
|| $value instanceof JsonSerializable;
}
Minimal snippet
Arr::arrayable(['sku' => 'SKU-1']); // true
Arr::arrayable(new StockAdjustment('SKU-1', 5)); // true
Documented way vs. discovered way
A manual instanceof Arrayable check is the natural first reach for "can this become an array",
but it only recognizes one of the shapes arrayable() accepts. StockAdjustment implements
JsonSerializable, not Arrayable - a manual check would reject it:
$adjustment = new StockAdjustment('SKU-2', 3);
$adjustment instanceof Arrayable; // false
Arr::arrayable($adjustment); // true
Real scenario: merging an adjustment of unknown origin into a report
StockLedger::withAdjustment() uses Arr::arrayable() to decide whether an incoming value
belongs in the report at all, before the report is built with Arr::from():
public function withAdjustment(Collection $snapshots, mixed $adjustment): Collection
{
if (! Arr::arrayable($adjustment)) {
return $snapshots;
}
return $snapshots->push($adjustment);
}
public function snapshotReport(Collection $snapshots): array
{
return $snapshots->map(fn ($item) => Arr::from($item))->all();
}
it('merges a valid adjustment into the snapshot report but leaves it untouched for a non-arrayable value', function () {
$snapshots = collect([['sku' => 'SKU-1', 'quantity' => 7]]);
$ledger = app(StockLedger::class);
$merged = $ledger->withAdjustment($snapshots, new StockAdjustment('SKU-2', 3));
$unchanged = $ledger->withAdjustment($snapshots, 'not-arrayable');
expect($ledger->snapshotReport($merged))->toBe([
['sku' => 'SKU-1', 'quantity' => 7],
['sku' => 'SKU-2', 'quantity' => 3],
])->and($unchanged->all())->toBe($snapshots->all());
});
The check and the conversion are deliberately two different methods: arrayable() only answers
"can this become an array", from() is the one that actually does it - one rejects unusable
input before the other has to.
Summary
| Entry | Documented alternative | When to prefer it |
|---|---|---|
Collection::getOrPut($key, $value) | A manual has()/get() check followed by put() | Reading a key that may not exist yet, storing a default in the same call if it's missing |
Collection::unshift(...$values) | prepend() called once per value | Prepending more than one item at once, in order, without a loop |
Collection::diffUsing($items, callable $callback) | diff()'s default loose comparison | The values need a custom equality rule diff()'s default comparison cannot express |
Collection::diffKeysUsing($items, callable $callback) | diffKeys()'s default loose comparison | Same, but comparing keys instead of values |
Collection::mapToDictionary(callable $callback) | groupBy() plus a second map() pass | Building plain-array buckets directly from a single callback returning one key/value pair |
Collection::toBase() | Re-wrapping manually via collect($derived->all()) | Downgrading a derived collection (e.g. Eloquent) back to a plain base Collection in one call |
Arr::arrayable($value) | A manual `is_array($value) |
Chapter 3 turns to standalone support classes usable directly - Env, Inspiring, Pipeline,
Manager, MultipleInstanceManager, and ProcessUtils - rather than further extensions of
Arr or Collection.