Skip to content

Time and clocks

Two sentences carry this whole chapter:

  • Every datetime is stored in UTC and stays there. A column holds an instant, and nothing about display or input ever changes what is in it.
  • A datetime shown or typed is read on somebody’s clock. There are exactly two clocks — the installation’s and the person’s — and one door answers which.

Everything below follows from those two. The rule lives in .ai/rules/clocks.md as well, because it applies to every module.

Hilms\Access\Support\ViewerTimezone::current() is the clock in front of this request: the signed-in person’s zone when they named one PHP still knows, the installation’s otherwise. A guest, a console command and a queued worker all get the installation’s. It costs nothing beyond the user the request has already resolved, so a listing of dates stays a listing of dates.

ViewerTimezone::current(); // whoever is reading
app(InstallationTimezone::class)->current(); // the site speaking about itself

The second is right for anything cached for everybody, because a cached value must not carry one reader’s zone: Branding::footerText() computes the copyright year that way, outside the forever-cached branding snapshot.

Hilms\Access\Support\Timezones is the list, from DateTimeZone::listIdentifiers() and memoised for the process: all(), has(), options() (each identifier with the offset it is on today) and offset(). A zone is never free text — the panel, the account page and the installer all validate against this list, and so does every read of a stored value, because an identifier PHP does not know would throw wherever the next date is formatted.

access keeps users.timezone but sits below the module that keeps the installation’s choice, so it asks through a contract, exactly as it asks for the languages it may not import:

Piece Where
Hilms\Access\Contracts\InstallationTimezone The question
Hilms\Access\Support\ConfiguredTimezone The floor: config('app.timezone'), which access binds
Hilms\Languages\Support\RegionTimezone What languages binds over it
Hilms\Languages\Settings\RegionSettings The stored identifier, group region, seeded to UTC
Hilms\Languages\Support\Region The cached snapshot every panel field reads
Hilms\Languages\Filament\Pages\ManageRegion The “Region and time” section of Settings, administrators only
Hilms\Access\Support\Timezones The list both halves offer
Hilms\Access\Support\ViewerTimezone The door everything else asks

Timezones lives in access rather than beside the settings because access is the module below and both halves of the answer — the installation’s zone and the person’s — are asked for there. The section sits beside Languages in Settings, under Site: which clock an installation keeps is the same kind of answer as which languages it speaks.

Region is a cached snapshot of scalars, like Languages and Branding::current(), forgotten by a SettingsSaved listener when the page is saved. It also memoises per request: Filament asks for the clock once per field drawn and once per table cell formatted, so a listing of datetimes would otherwise cost a cache round trip a cell. An installation whose settings table is not there yet, or whose stored identifier PHP does not know, answers with the configured timezone, so a fresh database, a test and the first minute of an installation all still have a clock.

AccessPlugin::boot() hangs the clock on the panel:

FilamentTimezone::set(ViewerTimezone::current(...));

A closure and not a string, because it is evaluated where it is asked — after the session, and after whoever is signing in is known. Filament reads it from DateTimePicker::getTimezone() for every field carrying a time and from CanFormatState for every table column and infolist entry, so that one call covers entry and display alike: a field hydrates UTC into the zone and dehydrates the zone back into UTC, and a record therefore stores the instant a person named on their own wall clock.

Nobody can be expected to infer which clock a field is in, so the same boot() adds a hint to every DateTimePicker that has a time in it, naming the zone the field itself answers with (access::clock.hint). A date with no time says nothing, because a date is the same date everywhere. It is the hint and not the helper text precisely so a field keeps whatever words it already had.

How a date is written follows the language, as its clock follows the person. Filament’s date() and dateTime() write one PHP pattern, M j, Y, in every language — an American order that put Polish month names first (“paź 3, 2026 20:13:45”). Every panel column and infolist entry asks with isoDate(), isoDateTime() or isoTime() instead, and LanguagesPlugin::boot() sets their defaults on every Table and Schema to Carbon’s own short formats, ll, lll and LT: “3 paź 2026 20:13” in Polish, “Oct 3, 2026 8:13 PM” in English, and right for any language an installation adds, with nothing to translate. PanelDatesTest fails on any panel code calling the fixed-pattern three. A test that renders a panel list boots the panel first (Filament::bootCurrentPanel()), because these defaults, like the clock above, are hung from a plugin’s boot().

Two fields pin a clock of their own, and both are the AI preferred window (window_start, window_end on the “AI assistants” section of Settings). That window is a rule a worker applies with nobody present, and RunWindow reads it in UTC, so both TimePickers are pinned to UTC and both say so in their helper text. Everything else — a course’s publication date, an entitlement’s expiry, a generation’s run time, a verified-at stamp — follows whoever is reading it.

<x-ui.date> is the only place a student-facing date is formatted:

<x-ui.date :value="$enrollment->expires_at" :phrase="__('learning::learning.dashboard.expires_on')" />
<x-ui.date :value="$passkey->created_at" time />

Hilms\Theme\Support\ViewerDate is the whole of what it does: it turns the stored instant into a <time datetime="…"> carrying that instant with its offset, and into words drawn on the clock the reader keeps. time adds the hour. phrase takes a translated sentence that still holds its own :date placeholder, so a translator puts the date where their language wants it; the sentence is escaped before the element goes in, so a phrase an editor rewrote on the Phrases page stays words and never markup.

ThemeStyleGuardTest fails any theme view that calls isoFormat, format, diffForHumans, toDateString and their kin, with a named exception list that is currently empty. A view that formats a date for itself tells the reader the wrong hour, and a column holds UTC, which is nobody’s wall clock.

Eloquent stores a datetime’s wall clock, not the instant it names. A DateTimeInterface carrying an offset is written as the numbers on its face: 2026-12-31T23:59:00+01:00 lands in the column as 23:59, an hour after the moment the caller meant. Filament dehydrates a picker to the app timezone and the API’s controller converts explicitly, so those two are safe — but safety at each call site is not an invariant. Hilms\Learning\Support\Grant normalises expiresAt to UTC as it is constructed, so every caller of the one door an entitlement comes through is safe without remembering. Do the same wherever a new instant enters from outside.

Raw date() is UTC, and is never the answer. Raw time() is fine for epoch arithmetic against a value the framework itself wrote with time() — Hilms\Access\Support\PasswordConfirmation compares the session’s auth.password_confirmed_at that way — but time() cannot be moved by travelTo(), so anything whose window a test has to prove reads the clock through the framework (Date::now()) instead.

A comparison of UTC against UTC agrees with itself, and the two ways of asking the same question have to agree on the boundary instant too. Course::isPublished() asks ! $this->published_at->isFuture(), so the very tick a course was typed to go live on counts as published — isPast() is strictly before now, and the two would disagree for exactly that tick while the published() scope let the row through.

/api/v1 emits every datetime as UTC, ISO 8601, with six decimals and a literal Z — 2026-09-08T20:44:34.000000Z — for granted_at, expires_at and revoked_at alike, whatever clock the value arrived on and whatever clock the installation keeps. And it reads a value that names no offset as UTC. Both halves are in SPECS.md §13 and in API and agents; the reason they are stated as a promise rather than left to Carbon’s defaults is that a stray serializeUsing(), a changed cast or a framework upgrade could move either of them in silence.

hilms:install asks for the zone once, through the SetTimezone step: --timezone=Europe/Warsaw first, then the terminal, and only while the installation is still on the configured zone — an installation that has chosen is never asked again. --no-interaction, which is what the container entrypoint passes, leaves it on UTC, and the panel page is where it is chosen afterwards. hilms:upgrade does not carry the step at all.

A datetime an operator types is read on the installation’s clock, because that is the wall clock they mean; one carrying an offset is honoured as given. hilms:user:entitle --expires= does that, and prints the stored expiry back on the same clock, as hilms:user:entitlements does. artisan about --only=hilms reports the zone among everything else.

Each of them proves one clock rather than a feature:

Test Proves
app-modules/access/tests/Feature/PanelClockTest.php That the panel’s fields and columns follow ViewerTimezone, and that the hint names the field’s own zone
app-modules/access/tests/Feature/PersonClockTest.php That a person’s own zone is written from /account and from the Users form, and read back
app-modules/catalog/tests/Feature/CourseClockTest.php That a course goes live at the instant that was typed
app-modules/learning/tests/Feature/EntitlementClockTest.php That an expiry ends access at the instant it names, whatever offset it arrived with
app-modules/ai/tests/Feature/GenerationClockTest.php That a run time is typed in the panel’s clock while the window stays in UTC
app-modules/api/tests/Feature/EntitlementApiTest.php The wire format, against a value stored from a non-UTC input
app-modules/theme/tests/Feature/DateAtomTest.php That <x-ui.date> draws the reader’s words over the stored instant

Two things make such a test easy to write wrongly:

  • A livewire() test does not boot the panel. Filament hangs panel-wide configuration in Panel::boot(), which a request reaches through the persistent SetUpPanel middleware. A Pest livewire() call mounts the component without any of it, so FilamentTimezone answers config('app.timezone') and a DateTimePicker silently works in UTC — which makes a timezone test pass for the wrong reason, or fail for a reason that is not the code’s. Call Filament::bootCurrentPanel() in the setup, or drive the page over HTTP.
  • Freeze far from the real year. date() and time() ignore travelTo(), so code that wrongly calls one of them still agrees with the frozen clock whenever the two fall in the same year, and the test passes for the wrong reason. BrandingTest freezes at 2099-12-31 23:30 UTC with the installation on a clock already into 2100, where the expected answer can never coincide with the real one.

HiLMS is MIT-licensed. No replicants were harmed in the writing of these books.