final class ApiDateTime (View source)

An immutable, UTC-only date/time value parsed from an API response.

The Team Internet APIs declare their date columns in UTC and emit exactly two shapes: a full timestamp (2026-07-25 07:46:34, optionally with a fractional second part, as CNR sends) and a bare calendar date (2030/07/17, as IBS/Moniker send — accepted directly, rather than being rewritten upstream). This class parses both into one flat struct and does nothing else — it is a parser, not a formatter. There is no in($tz), no locale formatting and no ext-intl dependency; presenting a value in the viewer's timezone is a display concern and belongs in the consuming application.

The date separator may be - or /, but must be consistent within one value — 2026-02/20 and 2026/02-20 are both rejected. {\CNIC\self::$date} and {\CNIC\self::$dateTime} always emit - regardless of the input separator, so a consumer never has to branch on which one the source used.

Responses are not rewritten to use this type. getPlain(), getHash() and getListHash() keep returning the raw API strings; this parser is opt-in at the point where a value is actually used.

A bare calendar date names no instant, so {\CNIC\self::$ts} and {\CNIC\self::$dateTime} are both null for one — deliberately, instead of defaulting to midnight, which would be an invented instant a consumer could not tell apart from a real one. {\CNIC\self::$date} is always populated.

{\CNIC\self::$raw} keeps the original input string exactly as given — the only place the discarded fractional-second precision, or the source's own separator, survives. It is for display, logging and round-trip fidelity only; comparison and sorting must use $ts or $date.

$dt = \CNIC\ApiDateTime::from("2026-07-25 07:46:34");
$dt->ts;       // 1784965594
$dt->date;     // "2026-07-25"
$dt->dateTime; // "2026-07-25 07:46:34"
$dt->raw;      // "2026-07-25 07:46:34"

// Presenting it elsewhere is the caller's job:
(new \DateTimeImmutable("@{$dt->ts}"))->setTimezone(new \DateTimeZone("Europe/Berlin"));

Constants

private TIMEZONE

The only timezone this type ever represents. The API declares UTC and the parser refuses offset-bearing input, so the value can never be anything else — see {self::$tz}.

private PATTERN

Shape gate for the two accepted formats, anchored at both ends.

This runs before createFromFormat() because that function alone is not strict enough: createFromFormat("!Y-m-d", "2026-7-1") succeeds with no warning at all, quietly accepting a format the API never sends.

The date separator may be - (CNR) or / (IBS/Moniker); the captured sep group plus the \k<sep> backreference requires the SAME separator both times, so a mixed value like 2026-02/20 is rejected rather than silently accepted. Only the space separator is accepted before a time part — the ISO T variant, a Z suffix and numeric offsets are all rejected rather than assumed to mean UTC.

The trailing D modifier (PCRE_DOLLAR_ENDONLY) matters because without it $ also matches immediately before a single trailing newline, so "2026-07-25\n" would pass this gate; $raw would then carry that newline into json_encode(), log lines and templates.

Properties

int|null $ts

Unix timestamp of the instant, or null when the source value was a bare calendar date and the instant is therefore unknown.

string $date

The calendar date as Y-m-d. Always populated, for both shapes.

string|null $dateTime

The full timestamp as Y-m-d H:i:s, or null for a date-only value.

string $tz

Timezone of the source declaration — always "UTC".

string $raw

The original input string, exactly as passed to {self::from()} / {self::tryFrom()} — no separator normalisation, no fractional-second stripping. Before the getDateTimeByKey()/getDateTimeByIndex() accessors existed, the only way to obtain an ApiDateTime was ApiDateTime::tryFrom($hash["paiduntil"]), so the caller necessarily still held the raw string alongside it. Those accessors close that gap — the raw value never passes through the integrator's own code — and recovering it afterwards would mean a second getDataByKey() lookup plus a re-narrow, exactly the double work the accessor exists to eliminate.

Methods

static ApiDateTime
from(string $value)

Parse an API date/time value.

static ApiDateTime|null
tryFrom(string|null $value)

Null-tolerant, non-throwing counterpart of {self::from()}.

bool
isDateOnly()

Whether the source value was a bare calendar date, naming no instant.

array
toArray()

The value as a plain array — ready for json_encode() and for handing to a template or frontend.

Details

static ApiDateTime from(string $value)

Parse an API date/time value.

Accepts Y-m-d H:i:s / Y/m/d H:i:s (with an optional fractional-second part, which is discarded) and Y-m-d / Y/m/d — the separator may be either, as long as it is the same one twice. Anything else throws — including values PHP would otherwise roll over silently, such as 2026-02-30 (which createFromFormat() turns into 2026-03-02) or 0000-00-00.

Parameters

string $value

Raw value as sent by the API

Return Value

ApiDateTime

Exceptions

InvalidDateTimeException

static ApiDateTime|null tryFrom(string|null $value)

Null-tolerant, non-throwing counterpart of {self::from()}.

Returns null for a null input and for anything from() would reject. Use it for optional columns that may be absent or empty; use from() when an unparsable value is a bug you want to hear about.

Parameters

string|null $value

Raw value as sent by the API, or null

Return Value

ApiDateTime|null

bool isDateOnly()

Whether the source value was a bare calendar date, naming no instant.

Equivalent to $dt->ts === null.

Return Value

bool

array toArray()

The value as a plain array — ready for json_encode() and for handing to a template or frontend.

Return Value

array