Number::pairs
Splits a range into consecutive [start, end] buckets for pagination or histogram bands.
Number::pairs(int|float $to, int|float $by, int|float $start = 0, int|float $offset = 1): list<array{int|float, int|float}>- Added in Laravel 11.13.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$$to | int|float | The upper end of the range being split. The loop stops once the lower bound reaches it. |
$$by | int|float | Bucket width. The absolute value is used, so a negative step behaves as positive. |
$$startoptional | int|float= 0 | Lower bound of the first bucket. |
$$offsetoptional | int|float= 1 | Subtracted from each bucket's upper bound. 1 gives touching, non-overlapping ranges; 0 makes each upper equal the next lower. |
Return value
list<array{int|float, int|float}>A list of two-element [lower, upper] arrays covering the range, with the final upper bound clamped to $to.
Errors
Throws InvalidArgumentException when $by is zero (Laravel 12.61.1 and later); earlier versions loop indefinitely.
Changelog
- PHP 11.13.0Added to the Number class.
- PHP 12.48.0The return type is annotated as a list of tuples, so static analysis now knows each element has exactly two members.
- PHP 12.61.1A zero $by throws InvalidArgumentException instead of looping forever, and a negative $by is normalised with abs().
Number::pairs() turns a range and a step into a list of [lower, upper] arrays. It is the helper for chunked work: id ranges to hand to queued jobs, page boundaries, or the bands of a histogram legend.
The offset decides whether the edges touch or overlap
Each bucket's upper bound is lower + by - offset. With the default offset: 1 you get [0, 9], [10, 19], [20, 25] — ranges you can safely use with whereBetween(), because no value belongs to two buckets. With offset: 0 you get [0, 10], [10, 20], [20, 25], where each upper equals the next lower; that shape suits >= lower AND < upper comparisons and continuous data, and double-counts if you use it with an inclusive between.
The last bucket is short
The final upper bound is clamped to $to, so the last pair is usually narrower than $by. Do not assume every bucket has the same width when you size a chart bar or estimate job duration.
Empty and invalid input
If $to is not above $start the result is an empty array — no bucket, no error. A zero $by throws InvalidArgumentException on current versions and hung the loop on older ones, so validate a step that comes from configuration.
Example
When to use it
- Splitting an id range into chunks to dispatch one queued job per chunk
- Building the bin boundaries for a histogram legend
- Generating page ranges for a report that is rendered in slices
Frequently asked questions
What does the offset parameter actually change?
It is subtracted from each bucket's upper bound. The default of 1 gives non-overlapping ranges like [0, 9] and [10, 19], safe for inclusive whereBetween; offset: 0 gives [0, 10] and [10, 20], where each upper equals the next lower, for half-open >= / < comparisons.
Why is the last pair smaller than the others?
Because the final upper bound is clamped to $to rather than overshooting it. Number::pairs(25, 10) ends with [20, 25], a six-wide bucket among ten-wide ones, so do not assume uniform width when sizing bars or estimating work.
What happens if the step is zero?
From Laravel 12.61.1 it throws InvalidArgumentException. On earlier versions the lower bound never advances and the loop runs forever, so validate any step that comes from configuration or user input before calling.
More examples
Dispatching one job per id chunk
The default offset of 1 is exactly right here: [0, 999], [1000, 1999] and so on never overlap, so whereBetween's inclusive bounds cannot process the same row twice. Destructuring in the foreach signature keeps the pair readable.
<?php
use App\Jobs\ReindexOrders;
use Illuminate\Support\Number;
$maxId = Order::max('id');
foreach (Number::pairs($maxId, 1000) as [$from, $to]) {
ReindexOrders::dispatch($from, $to);
}
// Inside the job:
// Order::whereBetween('id', [$this->from, $this->to])->each(...);Histogram bands with offset 0
offset: 0 produces [0,1], [1,2], [2,3], [3,4], [4,5] — touching edges suited to a half-open >= / < test on continuous values. Using the default offset of 1 here would give [0,0] and [1,1], which is meaningless for floats.
<?php
use Illuminate\Support\Number;
$durations = [0.4, 1.2, 1.9, 2.0, 3.7, 4.4, 4.9];
foreach (Number::pairs(5, 1, offset: 0) as [$low, $high]) {
$count = count(array_filter($durations, fn ($d) => $d >= $low && $d < $high));
printf("%3.1f–%3.1f %s\n", $low, $high, str_repeat('#', $count));
}Starting somewhere other than zero
The first call yields [[50, 74], [75, 99]] and the second [[50, 75], [75, 100]]. $start moves the first bucket's lower bound without changing $to, which is how you resume a chunked backfill from the last id you finished rather than re-walking the whole table.
<?php
use Illuminate\Support\Number;
print_r(Number::pairs(100, 25, start: 50));
print_r(Number::pairs(100, 25, start: 50, offset: 0));
Discussion