Repository navigation
Pagination and Collections
This page shows how pagination, counts, aggregates, ordering, chunking and collection methods behave when a query returns occurrences. Read it when you list occurrences in a UI or process them in batches.
The examples use two daily series and one plain record, queried over 2–6 March 2026:
Meeting::create(['title' => 'Alpha', 'location' => 'Room A', 'capacity' => 10,
'starts_at' => '2026-03-02 08:00:00', 'ends_at' => '2026-03-02 09:00:00',
'recurrence_rule' => 'FREQ=DAILY']);
Meeting::create(['title' => 'Bravo', 'location' => 'Room B', 'capacity' => 20,
'starts_at' => '2026-03-02 12:00:00', 'ends_at' => '2026-03-02 13:00:00',
'recurrence_rule' => 'FREQ=DAILY']);
Meeting::create(['title' => 'Charlie', 'location' => 'Room A', 'capacity' => 5,
'starts_at' => '2026-03-04 10:00:00', 'ends_at' => '2026-03-04 11:00:00']);
$week = fn () => Meeting::whereBetween('starts_at', ['2026-03-02 00:00:00', '2026-03-06 23:59:59']);That window holds 11 rows: five Alpha occurrences at 08:00, five Bravo occurrences at 12:00, and Charlie on 4 March at 10:00. The outcomes below are written as "title day time".
paginate() pages over the merged rows, and its total counts every occurrence:
$page = $week()->orderBy('starts_at')->paginate(4, page: 2);
$page->total(); // => 11
$page->items();
// => Alpha 04 08:00, Charlie 04 10:00, Bravo 04 12:00, Alpha 05 08:00simplePaginate() works the same way without the total:
$page = $week()->orderBy('starts_at')->simplePaginate(4, page: 3);
$page->items(); // => Bravo 05 12:00, Alpha 06 08:00, Bravo 06 12:00
$page->hasMorePages(); // => falsecursorPaginate() is not supported; see Limitations.
count, exists, doesntExist, min, max, sum and avg are computed over the occurrences, not over the stored rows.
$week()->count(); // => 11
$week()->min('starts_at'); // => '2026-03-02 08:00:00'
$week()->max('starts_at'); // => '2026-03-06 12:00:00'
$week()->sum('capacity'); // => 155 (5 × 10 + 5 × 20 + 5)
$week()->avg('capacity'); // => 14.09 (155 / 11)
$saturday = ['2026-03-07 00:00:00', '2026-03-07 23:59:59'];
Meeting::whereBetween('starts_at', $saturday)->exists(); // => true
Meeting::whereBetween('starts_at', $saturday)->where('title', 'Charlie')->exists(); // => falseOrdering works as on stored rows. Descending order returns the latest occurrence first:
$week()->orderByDesc('starts_at')->first(); // => Bravo 06 12:00Ordering by a non-time column, then by start:
$week()->orderBy('title')->orderBy('starts_at')->get();
// => Alpha 02–06 at 08:00, then Bravo 02–06 at 12:00, then Charlie 04 10:00Occurrences can tie on every column you order by, for example two series with the same title and start. When the query has at least one orderBy and is not grouped, distinct or a union, Groundhog appends tie-breakers (series key, original start, primary key), so pages never repeat or skip rows and the same query returns the same order every time. Adding a second Alpha series in "Room C" and paging orderBy('starts_at')->orderBy('title') four at a time gives 16 distinct rows across 4 pages, identical on every run.
Grouped and distinct queries keep only your ordering, because databases reject ORDER BY columns outside the grouped or selected set. Without any orderBy, row order is up to the database, as in plain Eloquent.
chunk(), each(), lazy() and cursor() visit every occurrence exactly once:
$seen = [];
$week()->chunk(3, function ($chunk) use (&$seen) {
foreach ($chunk as $meeting) {
$seen[] = $meeting;
}
});
count($seen); // => 11
$week()->lazy(3)->count(); // => 11
$week()->cursor()->count(); // => 11Key-based iteration (chunkById(), chunkByIdDesc(), eachById(), lazyById(), lazyByIdDesc()) needs a primary key on every row, which occurrences do not have. While occurrences are expanded these methods throw RecurrenceNotSupported (see Errors). Use chunk() or lazy() instead, or iterate stored rows with withoutOccurrences():
Meeting::whereBetween('starts_at', ['2026-03-01 00:00:00', '2026-03-31 23:59:59'])
->chunkById(3, fn ($chunk) => null);
// throws RecurrenceNotSupported
Meeting::withoutOccurrences()->chunkById(3, function ($chunk) {
// stored rows only: series, plain records and exceptions
});Query results are Eloquent collections. Occurrences have no key, so the collection compares them by their identity (series and original start) instead. Set operations therefore keep every occurrence distinct:
$all = $week()->get();
$all->unique()->count(); // => 11
$all->merge($all)->count(); // => 11
$all->diff($all->take(4))->count(); // => 7
$all->intersect($all->take(4))->count(); // => 4find() accepts an occurrence and returns the matching one from the collection:
$target = $all->first(fn ($m) => ! $m->exists && $m->title === 'Bravo' && $m->starts_at->format('d') === '04');
$found = $all->find($target);
$found->title; // => 'Bravo'
$found->starts_at; // => 2026-03-04 12:00When you select() explicit columns on a query that is neither grouped nor distinct, Groundhog adds the identity columns, so the returned occurrences stay recognisable and saveable:
$meetings = $week()->select(['title', 'starts_at'])->get();
$meetings->count(); // => 11
$alpha = $meetings->first(fn ($m) => $m->title === 'Alpha');
$alpha->isVirtualOccurrence(); // => truegroupBy, having and distinct run over the occurrences as rows. Counting rows per location:
use Illuminate\Support\Facades\DB;
$week()
->select('location', DB::raw('count(*) as n'))
->groupBy('location')
->havingRaw('count(*) > 1')
->orderBy('location')
->get()
->map(fn ($m) => $m->location.'='.$m->n)
->all();
// => ['Room A=6', 'Room B=5']
$week()->distinct()->select('title')->orderBy('title')->pluck('title')->all();
// => ['Alpha', 'Bravo', 'Charlie']Grouped and distinct rows are partial results, like partial Eloquent selections: read them, do not save them.
Next: Editing Occurrences
Documents Groundhog v0.2.0 · Repository · README · Changelog