Laravel Collections in 2026: The Methods You Are Not Using That Will Cut Your Code in Half

chunkBy() just landed in Laravel 13.30. lazy(), groupBy(), partition(), mapWithKeys(), whenNotEmpty(), tap(), pipe(), and the new memoized tagged cache — a complete tour of the Collection methods that experienced Laravel developers reach for and new developers keep reinventing with foreach loops.


A PR review comment, on a fifteen-line foreach loop that groups shipment records by carrier, filters out cancelled ones, and builds a summary array: “this is groupBy() and reject(), in two lines.” Not a stylistic nitpick — the fifteen-line version has a mutable accumulator array, a manually-tracked “have I seen this key before” check, and an off-by-one bug in the summary count that the two-line version structurally can’t have, because there’s no manual accumulator left to get wrong. This is the recurring shape of Collection method gaps in real codebases: not “the foreach loop doesn’t work,” but “the foreach loop is reimplementing a method that already exists, more verbosely and with more surface area for a subtle bug.”

This post is a tour of the specific methods worth having reflexively available — including chunkBy(), which landed in Laravel 13.30, and the new tagged Cache::memo() support from 13.33, both real and current as of this writing — organized around the foreach pattern each one actually replaces, because that’s the useful unit here, not an alphabetical API reference.


chunkBy() — Adjacency, Not Grouping, and the Distinction That Matters

Laravel 13.30 added chunkBy() as shorthand for the most common chunkWhile() pattern — splitting a collection into chunks wherever a resolved value changes between consecutive items.

// The foreach version — a mutable current-chunk accumulator,
// manually flushed when the parent changes
$chunks = [];
$currentChunk = [];
$lastParent = null;

foreach ($products as $product) {
    if ($lastParent !== null && $product->parent !== $lastParent) {
        $chunks[] = $currentChunk;
        $currentChunk = [];
    }
    $currentChunk[] = $product;
    $lastParent = $product->parent;
}
if (! empty($currentChunk)) {
    $chunks[] = $currentChunk;
}
// chunkBy() — the key goes through data_get(), so dot notation works
$chunks = $products->chunkBy('parent');

The distinction worth internalizing precisely, because it’s easy to reach for the wrong one: chunkBy() is not groupBy() with a different name. It only groups items that are adjacent to each other — a non-contiguous repeat of the same value starts a new chunk rather than joining the earlier one.

collect([1, 1, 2, 2, 1, 1])->chunkBy(fn ($v) => $v);
// [[1, 1], [2, 2], [1, 1]] — three chunks, the second run of 1s is separate

collect([1, 1, 2, 2, 1, 1])->groupBy(fn ($v) => $v);
// [1 => [1, 1, 1, 1], 2 => [2, 2]] — two groups, all 1s together regardless of position

chunkBy() also works on LazyCollection, which is exactly the case it’s most valuable for: splitting a large, pre-sorted database result into contiguous runs — by parent, by date, by status — while streaming, without ever loading the entire result set into memory to do it. groupBy() on the same data needs everything in memory first, because it has no way to know a later item belongs with an earlier group until it’s seen the whole collection.


lazy() — For When groupBy() Would Otherwise Exhaust Memory

// The foreach version — reads the entire orders table into memory
// before doing anything with it
$total = 0;
foreach (Order::where('status', 'completed')->get() as $order) {
    $total += $order->total;
}
// lazy() — a generator-backed cursor, one row in memory at a time,
// with the full Collection method API still available on top of it
$total = Order::where('status', 'completed')->lazy()->sum('total');

lazy() returns a LazyCollection — the same fluent API as a regular Collection, but backed by a PHP generator instead of a fully materialized array, meaning a table with ten million matching rows is processed one row at a time rather than pulling all ten million into memory before the loop even starts. This is the collections-layer version of the same discipline covered in earlier posts on cursor pagination and streamed processing — reach for it the moment “process every row matching this query” describes the actual task, rather than ->get() followed by a loop that happens to work fine on today’s row count and stops working the day the table crosses whatever memory limit is configured.


partition() — Two Groups, Not a Manual If/Else Accumulator

// The foreach version — two accumulator arrays, one condition,
// checked once per item
$passing = [];
$failing = [];

foreach ($students as $student) {
    if ($student->score >= 60) {
        $passing[] = $student;
    } else {
        $failing[] = $student;
    }
}
// partition() — the same split, expressed as what it actually is
[$passing, $failing] = $students->partition(fn ($student) => $student->score >= 60);

partition() is specifically for the binary-split case — a condition true or false, two resulting collections, destructured directly into two named variables. It’s a smaller, more specific tool than groupBy() (which handles an arbitrary number of groups keyed by any value), and reaching for the more specific tool when the situation is genuinely binary is what keeps the calling code reading as “this is a pass/fail split” rather than “this is a general grouping operation that happens to only produce two groups today.”


mapWithKeys() — Building an Associative Result Without a Manual Accumulator

// The foreach version — an accumulator array built key by key
$emailsByUserId = [];
foreach ($users as $user) {
    $emailsByUserId[$user->id] = $user->email;
}
// mapWithKeys() — the callback returns a single-entry array,
// which gets merged into the final result
$emailsByUserId = $users->mapWithKeys(fn ($user) => [$user->id => $user->email]);

The distinction from a plain map() worth being precise about: map() preserves the original keys and transforms each value; mapWithKeys() lets the callback specify both the new key and the new value for each item, which is exactly the shape needed whenever the goal is “build a lookup table keyed by some property of each item” rather than “transform each item in place.” Reaching for map()->keyBy() as two separate steps works, but mapWithKeys() expresses the same intent in one pass without an intermediate collection existing only to be immediately re-keyed.


whenNotEmpty() / when() — Conditional Chains Without Breaking the Fluent Pipeline

// The foreach-adjacent version — a chain broken by an if statement,
// with the collection variable reassigned conditionally outside the chain
$query = Product::query();

if ($request->filled('category')) {
    $query->where('category', $request->category);
}

$results = $query->get();
// when() — the condition lives inside the fluent chain itself
$results = Product::query()
    ->when($request->filled('category'), fn ($query) => $query->where('category', $request->category))
    ->get();
// whenNotEmpty() — the Collection equivalent, for a collection built
// earlier in the same chain rather than a query builder
$summary = $orders
    ->where('status', 'completed')
    ->whenNotEmpty(fn ($collection) => $collection->each(fn ($order) => $order->markProcessed()));

The value here isn’t merely fewer lines — it’s that the condition stays inside the same fluent expression instead of forcing the code to break out into an imperative if block that reassigns a variable declared outside the chain. For a long query-builder or collection pipeline, this keeps every step of the transformation reading as part of one continuous expression, which is meaningfully easier to scan than a chain interrupted by a conditional that temporarily drops back into procedural style.


tap() and pipe() — Side Effects and Transformations Without Breaking the Chain

// The foreach-adjacent version — the chain gets broken to log something
// in the middle of it, with an intermediate variable existing only to
// make the logging call possible
$filtered = $orders->where('status', 'pending');
Log::info('Pending orders found', ['count' => $filtered->count()]);
$result = $filtered->sortByDesc('created_at')->take(10);
// tap() — runs a side effect against the collection at that point in
// the chain, and returns the ORIGINAL collection unchanged afterward
$result = $orders
    ->where('status', 'pending')
    ->tap(fn ($collection) => Log::info('Pending orders found', ['count' => $collection->count()]))
    ->sortByDesc('created_at')
    ->take(10);
// pipe() — runs a callback against the collection and returns
// WHATEVER THE CALLBACK RETURNS, unlike tap()'s "always return original"
$averageAge = $users->pipe(fn ($collection) => $collection->avg('age'));

The distinction between these two is exactly the reason to know both by name rather than reaching for whichever one is remembered first: tap() is for a side effect that shouldn’t change the pipeline’s result — logging, dispatching an event, a debug dump — and always hands back the original, unmodified collection so the chain continues exactly as if tap() weren’t there. pipe() is for when the callback’s return value is the point — collapsing a collection into a single computed result (an average, a formatted string, a different data structure entirely) as a deliberate step in the chain rather than the chain’s continuation.


The New Tagged Memoized Cache — Collections’ Neighbor, Not a Collection Method, Worth Knowing Regardless

Laravel 13.33 extended Cache::memo() — introduced earlier to memoize a cache read for the duration of a single request or job, so repeated reads of the same key hit the cache backend once and come from memory after that — to work with tagged caches, closing a gap that previously required manually layering an array-store cache on top of a tagged one by hand.

// Before 13.33 — the manual workaround for memoizing WITHIN a request
// on top of a tagged cache that persists BETWEEN requests
public function getPermissions(User $user): Collection
{
    $cacheKey = "permissions:{$user->id}";

    return Cache::store('array')->tags('permission_cache')->rememberForever($cacheKey,
        function () use ($user, $cacheKey) {
            return Cache::tags($this->cacheTags($user))->remember(
                $cacheKey,
                config('auth.permissions.default_cache_time'),
                fn () => $this->loadPermissionsFromDatabase($user)
            );
        }
    );
}
// 13.33 — Cache::memo() handles the request-level memoization directly,
// with tags now behaving consistently whether memoized or not
public function getPermissions(User $user): Collection
{
    return Cache::memo()
        ->tags($this->cacheTags($user))
        ->remember(
            "permissions:{$user->id}",
            now()->addHour(),
            fn () => $this->loadPermissionsFromDatabase($user)
        );
}
$cache = Cache::memo()->tags(['permissions']);

$cache->get('permissions:1');  // reads from the cache store
$cache->get('permissions:1');  // reads from memory — no second round trip

$cache->put('permissions:1', $updated); // writes to the store, forgets
                                          // the memoized copy so the next
                                          // read reflects the new value

This is worth knowing alongside the Collection methods above precisely because it’s the same underlying discipline applied one layer down: a permissions check called multiple times in one request — once from a policy, once from a middleware, once from a Blade @can directive — no longer needs a hand-rolled array-cache wrapper to avoid three redundant round trips to Redis for the same tagged key. The pattern collapses into the same one-line Cache::memo() call already used for untagged reads, and writes/flushes stay correctly in sync with the memoized copy automatically.


The Actual Pattern Worth Internalizing

Every method in this post replaces the same underlying shape: a mutable accumulator variable, declared before a loop, mutated inside it, and read after it — the exact structure that introduces an off-by-one error, a forgotten initialization, or a condition checked in the wrong order, because the accumulator’s correctness depends on every iteration of the loop getting its bookkeeping exactly right. A named Collection method has no accumulator for a bug to hide inside, because the accumulation logic is already tested, inside the framework, independent of whatever specific data happens to be running through it this time.

Building two groups from one condition           → partition()
Building an associative array keyed by a property → mapWithKeys()
Splitting a sorted collection on adjacency         → chunkBy()
Processing a huge dataset without loading it all   → lazy()
Conditionally continuing a chain                    → when() / whenNotEmpty()
A side effect that shouldn't change the result       → tap()
Collapsing a collection into one computed value       → pipe()
Memoizing a tagged cache read within a request         → Cache::memo()->tags()

The One Rule

The foreach loop that started this post — fifteen lines, a mutable accumulator, an off-by-one bug — didn’t fail because the developer who wrote it didn’t understand the business logic. It failed because manual accumulation is a general-purpose tool being used for a specific, well-understood problem that already has a name and a tested implementation. The gap between developers who reach for chunkBy() or partition() reflexively and developers who reimplement them with a foreach loop isn’t a knowledge gap about Laravel trivia — it’s the difference between recognizing “this is a known shape” and treating every loop as a one-off problem to solve from scratch, with all the room for a subtle, accumulator-shaped bug that a from-scratch solution always carries and a named method never does.

Leave a Reply

Your email address will not be published. Required fields are marked *