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
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Funneling construction through a factory lets you guarantee every instance is already valid.
- 2Readonly promoted properties make a value object immutable with almost no boilerplate.
- 3Normalizing boundaries (start of day, end of day) removes an entire class of off-by-a-few-hours bugs.
Related explainers
go
package email import ( "errors"
Normalizing and deduping email addresses in Go
validation
normalization
deduplication
Intermediate
8 steps
php
<?php namespace App\Support;
Recursively finding files with SPL iterators in PHP
recursion
iterators
filesystem
Intermediate
7 steps
javascript
const express = require('express'); const multer = require('multer'); const path = require('path'); const crypto = require('crypto');
Safe image uploads with Multer in Express
file-upload
multer
validation
Intermediate
7 steps
go
package config import ( "fmt"
Parsing timeout config in Go
configuration
validation
error-wrapping
Intermediate
7 steps
go
func (h *WebhookHandler) HandleBatch(c *gin.Context) { var payload BatchWebhookPayload if err := c.ShouldBindJSON(&payload); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
Handling batched webhooks in Gin
webhooks
signature-verification
job-queue
Intermediate
7 steps
php
<?php namespace App\Http\Controllers\Api;
Building a cursor-paginated feed in Laravel
cursor-pagination
eager-loading
validation
Intermediate
8 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/a-validated-date-range-value-object-in-php-explained-php-9ed5/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.