Collections and Pagination
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Laravel 13.x (13.34.0) on PHP 8.3 to 8.5, checked on 2026-10-05. The examples run in
the Bookshelf application from a script that boots it, against PostgreSQL 17.11 for the models and SQLite for the
paginated routes. Illuminate\Support\Collection is a fluent wrapper around arrays that Eloquent returns for every
multi-row query; the helpers below (Str, Arr, Number, once) live in the same illuminate/support package.
PHP’s own array functions, which collections wrap, are on Arrays.
Collections
<?php
use Illuminate\Support\Arr;
$loans = collect([
['member' => 'ada', 'title' => 'Kindred', 'days' => 25, 'fee' => 100],
['member' => 'ben', 'title' => 'The Dispossessed', 'days' => 9, 'fee' => 0],
['member' => 'ada', 'title' => 'Dawn', 'days' => 31, 'fee' => 250],
['member' => 'cy', 'title' => 'Kindred', 'days' => 14, 'fee' => 0],
]);
echo 'overdue: ', $loans->where('fee', '>', 0)->pluck('title')->join(', ', ' and '), PHP_EOL;
echo 'fees by member: ', $loans->groupBy('member')->map->sum('fee')->sortDesc()->toJson(), PHP_EOL;
echo 'avg days: ', $loans->avg('days'), '; max: ', $loans->max('days'), '; median: ', $loans->median('days'), PHP_EOL;
[$late, $onTime] = $loans->partition(fn ($l) => $l['days'] > 21);
echo 'partition: ', $late->count(), ' late, ', $onTime->count(), ' on time', PHP_EOL;
echo 'keyBy+only: ', $loans->keyBy('title')->map(fn ($l) => Arr::only($l, ['member']))->toJson(), PHP_EOL;
echo 'chunk: ', $loans->pluck('days')->chunk(3)->toJson(), '; sliding(2) sums: ', $loans->pluck('days')->sliding(2)->map->sum()->join(','), PHP_EOL;
$titles = collect(['b', 'a']);
$titles->sort();
echo 'sort() result ignored: ', $titles->join(','), '; transform() mutates: ', $titles->transform(fn ($t) => strtoupper($t))->join(','), PHP_EOL;
overdue: Kindred and Dawn
fees by member: {"ada":350,"ben":0,"cy":0}
avg days: 19.75; max: 31; median: 19.5
partition: 2 late, 2 on time
keyBy+only: {"Kindred":{"member":"cy"},"The Dispossessed":{"member":"ben"},"Dawn":{"member":"ada"}}
chunk: [[25,9,31],{"3":14}]; sliding(2) sums: 34,40,45
sort() result ignored: b,a; transform() mutates: B,A
Three behaviours the output shows:
-
Methods return new collections.
sort()left$titlesunchanged; onlytransform(),push(),put(),forget(),pop(),shift(),splice()andprepend()modify the collection in place. -
Keys are preserved. The second chunk kept key
3, so it serialized as a JSON object{"3":14};filter(),where(),reject()andsortBy()keep keys the same way. Call->values()before returning a list to JavaScript. -
keyBy()keeps the last item for a duplicate key: two loans of Kindred became one, Cy’s.groupBy()keeps them all.
The methods by family:
| Family | Methods |
|---|---|
Transform |
|
Filter and search |
|
Order and slice |
|
Aggregate |
|
Combine and output |
|
Eloquent collections and higher-order messages
Queries return Illuminate\Database\Eloquent\Collection, which adds model-aware methods; higher-order messages
(->map->display_title, ->each->delete()) apply a property or method to every item:
<?php
use App\Models\Book;
$books = Book::with('author')->orderBy('title')->get();
echo get_class($books), ': ', $books->modelKeys() ? implode(',', $books->modelKeys()) : '', '; find(3): ', $books->find(3)?->title, PHP_EOL;
echo 'higher-order: ', $books->map->display_title->join('; '), PHP_EOL;
echo 'contains model: ', var_export($books->contains(Book::find(1)), true), '; except(1): ', $books->except(1)->count(), PHP_EOL;
Illuminate\Database\Eloquent\Collection: 1,2,3,4; find(3): The Dispossessed
higher-order: Kindred (1979); Parable of the Sower (1993); The Dispossessed (1974); The Lathe of Heaven (1971)
contains model: true; except(1): 3
Source: Laravel — Eloquent: Collections and Higher Order Messages
On an Eloquent collection find(), except() and only() work by primary key, contains() compares models by key,
and load(), loadMissing(), loadCount(), fresh(), makeHidden() and toQuery() act on all models at once.
$books->each->delete() deletes one by one with events, unlike Book::whereIn(...)->delete(); filtering a collection
in PHP after get() means the database sent rows it did not need to.
LazyCollection
LazyCollection wraps a generator: each method pulls items as needed, so a million-item source costs nothing until
iterated, and take(5) stops early:
<?php
use Illuminate\Support\LazyCollection;
$mem = memory_get_usage();
$sum = LazyCollection::make(function () { for ($i = 1; $i <= 1_000_000; $i++) { yield $i; } })
->filter(fn ($n) => $n % 3 === 0)->take(5)->sum();
printf("lazy: %d, %.1f KB used%s", $sum, (memory_get_usage() - $mem) / 1024, PHP_EOL);
// lazy: 45, 0.0 KB used
Source: Laravel — Lazy Collections
Book::lazy(), lazyById() and cursor() return lazy collections over query results, and LazyCollection::make(fn
() => yield from fopen(...)) streams files; generators themselves are on
Iterators and Generators.
Strings, arrays and numbers
<?php
use Illuminate\Support\{Arr, Number, Str};
echo Str::slug('Parable of the Sower (1993)'), ' | ', Str::of(' the lathe of heaven ')->trim()->title()->append('!'), ' | ',
Str::limit('The Dispossessed: An Ambiguous Utopia', 20), ' | ', Str::mask('ada@example.com', '*', 3, 6), ' | ',
Str::plural('review', 2), ' | ', Str::headline('late_fee_cents'), ' | ', Str::ulid()->toBase32() !== '' ? 'ulid ok' : '', PHP_EOL;
$config = ['shelf' => ['a' => ['capacity' => 40]]];
echo Arr::get($config, 'shelf.a.capacity'), ' ', json_encode(Arr::dot($config)), ' ', json_encode(Arr::wrap(null)), PHP_EOL;
echo Number::currency(1234.5, in: 'EUR', locale: 'es'), ' | ', Number::fileSize(7028), ' | ', Number::forHumans(1_250_000), ' | ',
Number::percentage(0.4567 * 100, precision: 1), ' | ', Number::spell(42), ' | ', Number::ordinal(3), PHP_EOL;
parable-of-the-sower-1993 | The Lathe Of Heaven! | The Dispossessed: An... | ada******le.com | reviews | Late Fee Cents | ulid ok
40 {"shelf.a.capacity":40} []
1.234,50 € | 7 KB | 1 million | 45.7% | forty-two | 3rd
Source: Laravel — Strings and Helpers: Numbers
Str::of() (or the str() helper) returns a fluent Stringable; Str::title() capitalises every word, including
"Of", so titles that follow style rules need Str::headline() or a custom list. Str::mask() hides characters for
display, not for security. The Number helpers use the intl extension, which is why Number::currency(..., locale:
'es') produced 1.234,50 €; without intl they throw. Arr::get() and data_get() read nested arrays with dot
notation and a default, which is what config('session.lifetime') does underneath.
once(), Pipeline and Benchmark
once(fn () => ...) caches a closure’s result for the rest of the request (or per object instance when called inside
a method), a lighter alternative to a property cache:
<?php
function shelfLabel(): int { return once(fn () => random_int(1, 1_000_000)); }
echo 'function: ', var_export(shelfLabel() === shelfLabel(), true), PHP_EOL;
class Catalogue
{
public int $calls = 0;
public function stats(): int { return once(function () { $this->calls++; return random_int(1, 1_000_000); }); }
}
$c = new Catalogue; $c->stats(); $c->stats();
$d = new Catalogue; $d->stats();
echo 'method: calls on $c=', $c->calls, ', on $d=', $d->calls, PHP_EOL;
$calls = 0;
$expensive = function () use (&$calls) { return once(function () use (&$calls) { $calls++; return random_int(1, 100); }); };
$a = $expensive(); $b = $expensive();
echo 'once(): same=', var_export($a === $b, true), ' calls=', $calls, PHP_EOL;
function: true
method: calls on $c=1, on $d=1
once(): same=false calls=2
Source: Laravel — once
The last case was not cached. once() builds its cache key from the call site and the values of the closure’s
use variables: the closure captured $calls by reference, $calls went from 0 to 1, so the second call had a
different key and ran again. Closures that capture changing state, and any code evaluated with eval() (including
Tinker), are not memoised.
<?php
use App\Models\Book;
use Illuminate\Support\Benchmark;
use Illuminate\Support\Facades\Pipeline;
$title = Pipeline::send(' dawn ')->through([
fn ($s, $next) => $next(trim($s)),
fn ($s, $next) => $next(ucwords($s)),
])->thenReturn();
echo 'pipeline: "', $title, '"', PHP_EOL;
[$count, $ms] = Benchmark::value(fn () => Book::count());
printf("benchmark: %d books in %.2f ms%s", $count, $ms, PHP_EOL);
// pipeline: "Dawn"
// benchmark: 4 books in 0.99 ms
Source: Laravel — Pipeline and Benchmarking
Pipeline is the mechanism middleware uses (Middleware): each stage receives
the value and a $next closure, and classes with a handle() method can be stages. Benchmark::measure() averages
several runs and Benchmark::dd() prints and stops; defer() (on
Request Lifecycle) runs a closure after the response.
Pagination
paginate(), simplePaginate() and cursorPaginate() read the page or cursor from the request, run the query and
return a paginator that renders links in Blade ({{ $books->links() }},
Blade Components and Layouts) and serializes to JSON when
returned from a route. Their SQL, captured with DB::listen() for page 3 of 5 books per page:
paginate():
select count(*) as "aggregate" from "books"
select "id", "title" from "books" order by "id" asc limit 5 offset 10
simplePaginate():
select "id", "title" from "books" order by "id" asc limit 6 offset 10
cursorPaginate():
select "id", "title" from "books" where ("id" > 10) order by "id" asc limit 6
-
paginate()runs acount(*)to know the number of pages, which is expensive on large tables and filtered joins. -
simplePaginate()fetches one extra row to know whether a next page exists, and offers only "previous" and "next". -
cursorPaginate()uses keyset pagination,where id > 10, so deep pages cost the same as the first and rows inserted meanwhile do not shift the pages; it needs anorderByon unique columns and cannot jump to page 7.
The theory of offset versus keyset pagination across databases is on Pagination: Offset vs. Keyset. The JSON each returns, from three routes in Bookshelf (data and links shortened):
{"current_page": 2, "data": [{"id": 6, "title": "Maiores hic aliquid."}, {"id": 7, "title": "Rerum molestiae laborum sint."}, "…"],
"first_page_url": "http://localhost:8000/p/offset?sort=id&page=1", "from": 6, "last_page": 6,
"last_page_url": "http://localhost:8000/p/offset?sort=id&page=6",
"links": [{"url": "http://localhost:8000/p/offset?sort=id&page=1", "label": "« Previous", "page": 1, "active": false},
{"url": "http://localhost:8000/p/offset?sort=id&page=1", "label": "1", "page": 1, "active": false},
{"url": "http://localhost:8000/p/offset?sort=id&page=2", "label": "2", "page": 2, "active": true}, "…"],
"next_page_url": "http://localhost:8000/p/offset?sort=id&page=3", "path": "http://localhost:8000/p/offset", "per_page": 5,
"prev_page_url": "http://localhost:8000/p/offset?sort=id&page=1", "to": 10, "total": 26}
The route used ->paginate(5)->withQueryString(), so the sort=id parameter was kept in every link; without it,
filters are lost when the user moves to the next page. The label values contain the HTML entity «, meant for
Blade, which a JavaScript client must decode or replace. simplePaginate() returns the same shape without total,
last_page and links; cursorPaginate() returns next_cursor and prev_cursor instead of page numbers, base64
JSON such as {"id":5,"_pointsToNextItems":true}, which the client sends back as ?cursor=. For API responses,
paginated API Resources wrap the same data in data, links and meta keys
(Building APIs and API Resources).
Where older books and tutorials are outdated
-
array_map()/array_filter()chains over query results (Kromann, Nixon) are collection pipelines in Laravel code, with the key-preservation caveat above. -
str_slug(),array_get()and the other global string and array helpers (Stauffer ch. 17) were removed in Laravel 6; useStr::slug(),Arr::get()or thestr()helper. -
Offset pagination everywhere is the default, but
cursorPaginate()(Laravel 8) is the better fit for infinite scrolling and large tables. -
Memoising with
static $cachevariables in functions can usually beonce()(Laravel 11), as long as the closure does not capture changing state. -
Number::format()and friends (Laravel 10) replace hand-writtennumber_format()and locale code.