psx/datetime
PSX DateTime is a PHP library for working with dates and times, offering utilities for parsing, formatting, timezone conversion, and date arithmetic. It integrates with the PSX framework and provides a lightweight, object-oriented API for common temporal tasks in PHP applications.
psx/datetime is a plain Composer library — no service provider, no config file, no facade. Install it and it's immediately usable:
composer require psx/datetime
Check composer.json for the PHP floor first; the package tracks modern PHP but you don't want to discover a constraint clash mid-sprint on a legacy app.
Where to look first: the src/ directory. The classes map 1:1 onto java.time, so if you know the Java API you already know this one:
DateTime — the workhorse, an immutable date-time with offset/zone; start here.LocalDate, LocalTime, LocalDateTime — zone-less value objects for domain data.Instant, Duration, Period — machine time, time-based amounts, date-based amounts.ZoneId, ZoneOffset, DayOfWeek, Month — supporting types.First use case: parse an ISO-8601 string from a third-party API, do arithmetic, and format it back out — without Carbon's mutability footguns:
use PSX\DateTime\DateTime;
use PSX\DateTime\Duration;
$dt = DateTime::parse('2024-03-04T12:00:00+00:00');
$later = $dt->plus(Duration::ofHours(3)); // new instance, $dt untouched
echo $dt->format('Y-m-d H:i:sP'); // 2024-03-04 12:00:00+00:00
echo $later->format('Y-m-d H:i:sP'); // 2024-03-04 15:00:00+00:00
For date-only domain concepts (billing dates, birthdays, report periods), reach for LocalDate instead — it makes "no time component" explicit in your type signatures:
use PSX\DateTime\LocalDate;
$start = LocalDate::of(2024, 3, 4);
$end = $start->plusDays(10);
Convert at the boundary, keep PSX types inside. Laravel — Eloquent casts, Blade's diffForHumans, the date validation rule, Carbon::setTestNow() — speaks Carbon. Treat psx/datetime as an internal domain type and translate on the way in and out. DateTime is a \DateTimeImmutable subclass, so it satisfies \DateTimeInterface and can be handed to Carbon via Carbon::instance($psxDateTime) for presentation; LocalDate/LocalTime bridge through atTime()/atStartOfDay().
Custom Eloquent cast as the adapter. This is the single highest-value integration point:
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use PSX\DateTime\LocalDate;
class LocalDateCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes): ?LocalDate
{
return $value === null ? null : LocalDate::parse($value);
}
public function set($model, $key, $value, $attributes): ?string
{
return $value === null ? null : $value->format('Y-m-d');
}
}
Map LocalDate → DATE columns, LocalDateTime → DATETIME (no zone), and DateTime/ZonedDateTime → TIMESTAMP or a string column normalised to UTC. Never store a zoned value in a naive DATETIME and hope.
Validation rule for inbound ISO strings. Rather than letting a parse exception escape as a 500, wrap the parser:
Validator::extend('iso_date', fn ($attr, $value) => rescue(
fn () => LocalDate::parse($value), fn () => null
) !== null);
JSON resources. The types serialise to ISO-8601, which is exactly what you want on the wire. Return them directly from API resources or cast to (string); do not let them fall through to json_encode on a wrapper object you haven't checked.
Calendar-aware diffs with Period. Period::between($start, $end) gives you years/months/days — the right primitive for "3 months and 2 days remaining" wording, pro-rata billing, and subscription proration. Use Duration::between() when you genuinely mean elapsed time (SLA timers, cache TTLs).
Business-calendar rules. DayOfWeek/Month make weekday logic readable and avoid magic integers scattered through a service class.
Everything is immutable — reassign. $date->plusDays(1); on its own line is a no-op. Coming from Carbon's mutating addDay(), this is the #1 source of silent bugs. On the plus side, no ->copy() before branching logic.
Period ≠ Duration. Duration is time-based (seconds, nanos); Period is date-based (years, months, days). Passing one where plus() expects the other either fails or gives you a result you didn't intend. Pick based on whether DST and month lengths should affect the answer.
Java overflow semantics differ from PHP's. LocalDate::of(2024, 1, 31)->plusMonths(1) clamps to 2024-02-29. \DateTime would roll to March 2. This is a feature for billing cycles and a regression if you're porting existing Carbon date math — write a test pinning the behaviour.
DST arithmetic follows Java rules. Adding one day across a DST boundary preserves local wall time; adding 24 hours as a Duration does not. Be explicit about which you mean, especially for schedulers and reminders.
No Carbon::setTestNow() equivalent. You can't freeze now(). Inject DateTime values as constructor/method dependencies instead of calling now() deep in a service, or your tests will be flaky. This is good design pressure, but it's a real migration cost.
(string) casts surprise people. Most types stringify to ISO-8601, which is fine in logs but wrong when a human expects d/m/Y. Always call format() explicitly at the display layer.
Parse failures throw. Wrap untrusted input in validation or rescue(); don't let a malformed third-party payload blow up a queued job.
Low-traffic package — pin and wrap it. Two stars and zero dependents means the docs are thin and the API surface is whatever the source says. Pin an exact version in composer.json, keep your PSX usage behind a small adapter (casts, a Clock-style wrapper, resource transformers), and you retain the option to swap back to Carbon without touching the domain layer.
Extension points are plain PHP. Subclass or wrap these types in your own value objects, implement JsonSerializable/Stringable for custom serialisation, and lean on Laravel's cast and rule contracts as the integration seam. There is no framework magic to fight — which is both the package's appeal and the reason it won't feel native in a Laravel codebase without that adapter layer.
How can I help you explore Laravel packages today?