league/period
League\Period is PHP's missing time range API, offering immutable Interval and Bounds value objects, named constructors, and full support for simple or complex time range logic. Framework-agnostic, documented, and requiring PHP 8.1+.
Install and inspect the version first. This package has been through several major renames, and almost every "why doesn't this method exist" question is a version question:
composer require league/period
composer show league/period # note the version before copying snippets
League\Period\Period; boundary behaviour is set with integer constants like Period::INCLUDE_START_EXCLUDE_END, and splitting is split()/splitForward().Interval with a Bounds enum (Bounds::IncludeStartExcludeEnd, etc.), and Period remains mainly as a backwards-compatibility alias. If a snippet from a blog post fails, check whether it uses Period:: where your installed version expects Interval::.Where to look first, in order:
README.md — install + a one-line example; it deliberately stays short.period.thephpleague.com) — this is where the API is actually explained; the README is not a tutorial.src/ — the classes are small and readable (Interval/Period, Datepoint/DatePoint, Duration, Sequence, the Bounds enum). Reading Interval end-to-end takes 15 minutes and answers most questions faster than searching.CHANGELOG.md — your upgrade guide. Deprecations here are the single biggest source of confusion (getDatePeriod → dateRangeForward, getDateInterval → dateInterval, sameValueAs → equals, createFrom* → named constructors).First use case — booking/slot validation. This is what most teams actually pull the library in for: deciding whether a requested time range sits inside opening hours, collides with existing bookings, or abuts the previous one cleanly.
use League\Period\Period; // Interval on 5.x
use League\Period\Bounds;
$openingHours = Period::fromDateRange('2024-03-15 09:00', '2024-03-15 17:00');
$requested = Period::fromDateRange('2024-03-15 16:00', '2024-03-15 18:00');
$existing = Period::fromDateRange('2024-03-15 10:00', '2024-03-15 11:30');
$openingHours->contains($requested->startDate); // true
$requested->overlaps($existing); // false
$requested->overlaps($openingHours); // true -> reject
$requested->isAfter($openingHours); // false
Default boundary semantics are half-open, [start, end) — that single fact drives most of the gotchas below.
1. Named constructors instead of hand-built ranges. Calendar units are first-class and timezone-aware; don't reimplement month/quarter boundaries with date() maths.
use League\Period\Datepoint;
Datepoint::fromDateString('2024-03-15')->month()->toIso80000('Y-m-d');
// [2024-03-01, 2024-04-01)
Datepoint::fromDateString('2024-03-15')->quarter();
Datepoint::fromDateString('2024-03-15')->isoWeek();
Datepoint::fromDateString('2024-03-15')->semester();
Relative constructors cover the "window around a point" cases cheaply: Period::after($start, $duration), Period::before($end, $duration), Period::around($point, $duration), Period::fromDatepoint($point, $duration).
2. Model billing/reporting buckets with splitForward(). Reporting that "buckets by day" is exactly a split, and the generator keeps memory flat for long ranges.
foreach ($month->splitForward(Duration::fromDateString('1 day')) as $day) {
$counts[$day->startDate->format('Y-m-d')] = $this->countEvents($day);
}
3. Use immutable Periods as value objects in your domain. They're safe to put in DTOs, cache payloads, and collections because every mutator returns a new instance. A common pattern is a small wrapper:
final class BillingWindow
{
public function __construct(public readonly Period $period) {}
public function prorate(int $totalDays): float { /* ... */ }
}
4. Prefer Sequence over manual array juggling. For "free slots", "merged availability", "total booked time", Sequence is the shortest correct code:
use League\Period\Sequence;
$booked = new Sequence(...$periods);
$free = (new Sequence($openingHours))->subtract($booked); // gaps
$total = $booked->totalTimeDuration(); // absolute seconds
5. Laravel/Eloquent integration. Two patterns carry most of the weight.
Query scopes with SQL overlap — mirror the half-open semantics exactly:
public function scopeOverlapping(Builder $q, Period $period): Builder
{
return $q->where('starts_at', '<', $period->endDate) // strictly before end
->where('ends_at', '>', $period->startDate); // strictly after start
}
Hydrate a Period from two columns via a custom cast:
public function get(Period $model, string $key, mixed $value, array $attributes): ?Period
{
return isset($attributes['starts_at'], $attributes['ends_at'])
? Period::fromDateRange($attributes['starts_at'], $attributes['ends_at'])
: null;
}
6. Validate, don't silently clamp. Turn overlap logic into a form request rule: reject when the incoming range overlaps() any existing one. Keeps the domain rules in one place and out of controllers.
7. Serialize at the edges only. toIso8601() for machine output, toIso80000($format) for human-facing strings, and jsonSerialize() (JS-compatible datepoint notation) when the Period is returned from an API resource. Keep DB storage as two timestamp columns — the library is not an ORM type.
contains($end) is false, and a zero-duration Period contains nothing at all — not even its start. If you need inclusive reporting, be explicit: boundedBy(Bounds::IncludeAll) (or withBoundaryType(...) on 4.x). Never rely on the default matching a user's mental "9am to 5pm inclusive".BETWEEN is the classic mismatch. BETWEEN is inclusive; Period is not. The < / > pair shown above is the correct translation and also prevents double-counting an appointment touching the boundary.$period->boundedBy($bounds); on its own does nothing. Most "it's not working" bug reports are an unassigned return value — same for gap(), intersect(), expand().DateTimeImmutable everywhere. Inputs are normalised; mutable DateTime objects don't get mutated behind your back, and mutating your own object after building a Period will not affect the Period.DateTimeImmutable but does not convert endpoints for you — construct both ends in the same timezone. A Duration is calendar-aware (DateInterval), so timeDuration() (absolute seconds) and dateInterval() can disagree across a DST change. Use timeDuration() for money and metrics, dateInterval() for calendar arithmetic. Splitting a range that spans a DST switch is the historical bug cluster here — pin a version and test that case explicitly.intersect() on disjoint periods. Guard with overlaps() first; depending on version and shape this returns null or throws. Same for gap().equals() ≠ ===. Equality compares value and boundary type. Two Periods with identical timestamps but different Bounds are not equal and will sort/collapse differently.splitForward() returns a Generator. It's single-pass — iterator_to_array() to reuse it, and don't assume array functions work on it.split/splitForward, getDatePeriod/dateRangeForward, format/toIso80000, withBoundaryType/boundedBy, Period::INCLUDE_*/Bounds::*. Deprecated aliases usually still work but will drown your static analysis in warnings; search-and-replace on a major bump is cheaper than tolerating them.Period/Interval are effectively final in modern versions; extend by composition (wrapper class holding the Period) rather than subclassing. The natural seams are your own DTOs, casts, and query scopes.$period->toIso8601() rather than the object — it shows the boundary notation and endpoints, which is where 90% of the confusion lives ([2024-03-15T00:00:00+00:00, 2024-03-16T00:00:00+00:00)).How can I help you explore Laravel packages today?