php 64 lines · 7 steps

A validated date-range value object in PHP

A self-validating value object that parses, checks, and normalizes a start/end date pair before you can construct it.

Explained by highlit
1<?php
2 
3namespace App\Http\Requests;
4 
5use DateTimeImmutable;
6use DateTimeZone;
7use InvalidArgumentException;
8 
9final class DateRange
10{
11 private const MAX_SPAN_DAYS = 366;
12 
13 public function __construct(
14 public readonly DateTimeImmutable $start,
15 public readonly DateTimeImmutable $end,
16 ) {
17 }
18 
19 public static function fromForm(array $input, string $timezone = 'UTC'): self
20 {
21 $tz = new DateTimeZone($timezone);
22 $start = self::parseDate($input['start_date'] ?? null, $tz, 'start_date');
23 $end = self::parseDate($input['end_date'] ?? null, $tz, 'end_date');
24 
25 $start = $start->setTime(0, 0, 0);
26 $end = $end->setTime(23, 59, 59);
27 
28 if ($end < $start) {
29 throw new InvalidArgumentException('end_date must be on or after start_date.');
30 }
31 
32 if ($start->diff($end)->days > self::MAX_SPAN_DAYS) {
33 throw new InvalidArgumentException(
34 sprintf('Range may not exceed %d days.', self::MAX_SPAN_DAYS)
35 );
36 }
37 
38 return new self($start, $end);
39 }
40 
41 private static function parseDate(mixed $value, DateTimeZone $tz, string $field): DateTimeImmutable
42 {
43 if (!is_string($value) || trim($value) === '') {
44 throw new InvalidArgumentException("{$field} is required.");
45 }
46 
47 $date = DateTimeImmutable::createFromFormat('!Y-m-d', trim($value), $tz);
48 $errors = DateTimeImmutable::getLastErrors();
49 
50 if ($date === false || ($errors && ($errors['warning_count'] || $errors['error_count']))) {
51 throw new InvalidArgumentException("{$field} must be a valid date (Y-m-d).");
52 }
53 
54 return $date;
55 }
56 
57 public function toArray(): array
58 {
59 return [
60 'start' => $this->start->format('Y-m-d'),
61 'end' => $this->end->format('Y-m-d'),
62 ];
63 }
64}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1Funneling construction through a factory lets you guarantee every instance is already valid.
  2. 2Readonly promoted properties make a value object immutable with almost no boilerplate.
  3. 3Normalizing boundaries (start of day, end of day) removes an entire class of off-by-a-few-hours bugs.

Related explainers

Share this explainer

Here's the card — post it anywhere.

A validated date-range value object in PHP — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code