belamov/postgres-range
Laravel package for working with PostgreSQL range types. Cast Eloquent attributes into range objects (int4range, numrange, tsrange, daterange) and use query helpers for contains, overlaps, and other range operations, all without writing raw SQL.
belamov/postgres-range adds first-class support for PostgreSQL built-in range types (daterange, tsrange, tstzrange, int4range, int8range, numrange) to Eloquent. It is a small package: a service provider, a handful of cast classes, and range value objects. You can read the whole src/ directory in one sitting.
Install with Composer:
composer require belamov/postgres-range
The service provider is auto-discovered. No config file or migration is published; you create range columns yourself:
Schema::create('bookings', function (Blueprint $table) {
$table->id();
$table->daterange('period');
$table->timestamps();
});
Then attach a cast to the model. The cast maps the database value to a range object and back:
use Belamov\PostgresRange\Casts\DateRangeCast;
class Booking extends Model
{
protected $casts = [
'period' => DateRangeCast::class,
];
}
First real use case: availability checks. Create a range and ask for overlaps:
use Belamov\PostgresRange\Ranges\DateRange;
$requested = DateRange::fromBounds('2024-07-01', '2024-07-08');
$isTaken = Booking::whereRangeOverlaps('period', $requested)->exists();
Where to look first:
src/Ranges/ — DateRange, DateTimeRange, TimestampRange, IntegerRange, NumericRange.src/Casts/ — one cast per range type.src/PostgresRangeServiceProvider.php — registers the query builder macros.fromBounds, fromString, and the available whereRange* macros.Use interval/range objects as value objects, not raw strings. Prefer fromBounds() in application code because it is explicit about inclusivity, and fromString() when you are round-tripping a PostgreSQL literal:
$range = DateRange::fromBounds('2024-01-01', '2024-01-31'); // [2024-01-01,2024-01-31)
$range = DateRange::fromString('[2024-01-01,2024-01-31)');
Reuse the same range object for in-memory and query checks. The range classes implement contains(), overlaps(), and isContainedBy(), so pre-query validation and database filtering can share logic:
if ($existing->period->overlaps($requested)) {
abort(422, 'Period is already booked');
}
Booking::whereRangeOverlaps('period', $requested)->exists();
Wrap recurring queries in local scopes. This keeps the PostgreSQL operators out of controllers:
public function scopeOverlapping(Builder $query, Range $range): Builder
{
return $query->whereRangeOverlaps('period', $range);
}
public function scopeContainedBy(Builder $query, Range $range): Builder
{
return $query->whereRangeContainedBy('period', $range);
}
The three macros most teams use are:
| Macro | PostgreSQL operator | Meaning |
|---|---|---|
whereRangeOverlaps($column, $range) |
&& |
Two ranges share at least one point |
whereRangeContains($column, $range) |
@> |
Column range fully contains the given range |
whereRangeContainedBy($column, $range) |
<@ |
Column range is fully inside the given range |
Validate first, then build the range. Form requests should validate ordering, then the controller constructs a range:
$data = $request->validate([
'from' => ['required', 'date'],
'to' => ['required', 'date', 'after:from'],
]);
$range = DateRange::fromBounds($data['from'], $data['to']);
Normalize at the API boundary. Range objects are not plain scalars, so cast to string in resources:
return [
'period' => (string) $booking->period, // "[2024-01-01,2024-01-31)"
];
Assign ranges through the cast. Eloquent accepts the range object directly:
$booking->period = DateRange::fromBounds($from, $to);
$booking->save();
Testing requires PostgreSQL. Unit-test range logic with plain PHP, but feature tests that hit whereRange* need a PostgreSQL connection. Use a dedicated test database or Testcontainers rather than SQLite.
PostgreSQL only. SQLite and MySQL have no native range types. Tests on SQLite will either fail to migrate or silently treat daterange as text-like. Keep a PostgreSQL service in CI.
Bound inclusivity is normalized by PostgreSQL. int4range and int8range canonicalize to [lower, upper); for example, (1,10] is stored as [2,11). Do not assert exact literal strings unless you have tested the server behavior. Date and timestamp ranges do not discretize the same way, but (] versus [) still matters.
Empty ranges exist. A range like [2024-01-01,2024-01-01) is empty. Comparisons such as overlaps() correctly return false, but displaying or logging the value can be confusing.
Use null for unbounded ends. fromBounds(null, '2024-12-31') produces a range open on the lower side. Be deliberate about whether null means "unbounded" or "missing data" in your domain.
Check for null attributes. A nullable range column yields null from the cast, not an empty range object. Guard with $model->period?->contains($other).
Index range columns with GiST. B-tree indexes do not help &&/@> queries. Add one manually:
DB::statement('CREATE INDEX bookings_period_gist ON bookings USING GIST (period)');
Argument order matters. whereRangeContains('period', $range) means "the period column contains $range". Swapping the column and range is a common source of inverted results.
Inspect generated SQL when debugging. Use ->toSql() and ->getBindings(); ranges are bound as PostgreSQL literals such as [2024-01-01,2024-01-31). If a query returns nothing, confirm the literal is what you expect.
Time zone handling. tsrange is timezone-naive; tstzrange is timezone-aware. Pick the column type and cast deliberately, especially for scheduling across zones. The package's timestamp casts assume the values written to the database are already in the intended zone.
Laravel 13 support is new in 1.3.1. If you are testing against Laravel 13, require ^1.3.1. The PHPUnit 12 and StyleCI changes in this release are package-development concerns and do not affect consumers; no breaking changes are introduced.
Extending. For a range type not covered by the built-in casts, subclass the package's range base class and pair it with a custom cast that formats the PostgreSQL literal. Keep the toString() format compatible with what PostgreSQL accepts, or queries will fail at the database layer rather than in PHP.
Serialization. Do not assume a range object JSON-encodes into a useful structure. Explicitly convert to (string) in API resources and form data.
How can I help you explore Laravel packages today?