Developers

Dates

A hub has members in every time zone, so it keeps one rule:

Hubzero\Utility\Date extends PHP's DateTime and gives you the conversions. It does not enforce the rule — nothing does — so the whole of this page is about which side of the line a given value is on.

The whole rule, as a table

An instrument-booking component reads a start time from a form, stores it, compares it against now, and shows it back. That is five values and five different calls:

The value Write
Read from a database column Date::of($row->get('starts')) — already UTC, no zone argument
Typed by a member into a form Date::of($input, Config::get('offset')) — their zone, said out loud
Now, on the way into a column Date::of('now')->toSql()
On the way to a member's screen ->toLocal(Lang::txt('DATE_FORMAT_HZ1'))
On the way to a machine — JSON, a feed, a <time datetime=""> ->format('Y-m-d\TH:i:s\Z') or toISO8601()

What getting it wrong looks like

Nothing throws. No warning is logged. A booking made for 9am appears at 1pm, or a job that should have run at midnight runs at five. The reliable tell is that the error is a whole number of hours and changes by one in summer, because the offset that was wrongly applied is a daylight-saving one.

The second tell is that comparisons stop agreeing with the display. A row written in local time sorts and filters wrongly against every other row: publish_up <= now is evaluated in the database against UTC, so a booking stored four hours ahead of where it should be simply does not appear until four hours later, on a page that shows the right time all along.

Creating a date

use Date;

$now     = Date::of('now');
$created = Date::of($row->get('created'));

Date::of($date = 'now', $tz = null, $ignoreDst = false) returns a new object each time; there is no shared instance. Date::getRoot() is Date::of('now'). $ignoreDst pins the zone to its standard offset, which is wanted only for a recurring wall-clock time that must not shift with daylight saving; leave it alone otherwise.

When $tz is omitted the string is read as UTC. That is the important default. Date::of('2015-06-01 09:00:00') is nine in the morning UTC, not nine in the morning wherever the server is. A value that came out of the database is already UTC, so reading it back needs no time zone:

$created = Date::of($row->get('created'));

A value that came from a member typing into a form is in their time zone, and has to be told so. This is the one line that matters, and the one that gets left out:

// The member typed "2026-09-14 09:00" meaning nine in the morning where they are
$fields['starts'] = Date::of($fields['starts'], Config::get('offset'))->toSql();

Leave the second argument off and the same string is stored as nine in the morning UTC, which is four or five in the morning for the lab. The booking saves, the form redisplays it correctly, and only the slot list disagrees.

Config::get('offset') is the hub's configured zone. $tz accepts a DateTimeZone, an identifier string such as America/New_York, or a numeric offset resolved through the class's own table of common zones.

The constructor accepts anything strtotime() does, and treats a purely numeric argument as a Unix timestamp:

Input Example
MySQL datetime 2009-10-02 15:25:00
Unix timestamp 1254497100
RFC 2822 Fri, 2 Oct 2009 15:25:00 +0000
RFC 3339 / ISO 8601 2009-10-02T15:25:00+00:00
Plain English 2 October 2009, now, -1 year

Where the input carries its own offset, that offset is honoured and the result still represents the same instant.

Storing

$row->set('modified', Date::of('now')->toSql());

toSql($local = false, $dbo = null) formats for the database using the driver's own date format, in UTC unless $local is true. Leave $local alone. Every comparison the CMS makes — publish_up <= now, a cron job's next_run — assumes UTC on both sides, and one row written in local time sorts and filters wrongly against all the others.

Displaying

toLocal($format = '') converts to the viewing member's zone and formats:

echo Date::of($booking->get('starts'))->toLocal(Lang::txt('DATE_FORMAT_LC2'));

It reads User::getParam('timezone', Config::get('offset')) — the member's own preference where they have set one, the hub's offset otherwise — so the same row renders differently for two members, which is the point.

The format is a PHP date() format string, not a strftime() one: 'd M Y', not '%d %b %Y'. Put it in a language key rather than a view so a hub can change it:

Key Value
DATE_FORMAT_HZ1 d M Y
DATE_FORMAT_LC l, d F Y
DATE_FORMAT_LC2 l, d F Y H:i
DATE_FORMAT_LC3 d F Y
DATE_FORMAT_LC4 Y-m-d
TIME_FORMAT_HZ1 g:i a

Called with no format, toLocal() uses Date::$format, which is Y-m-d H:i:s.

Method Returns
toLocal($format = '') Formatted in the viewer's time zone
toTimeZone($tz, $format = null) Formatted in a zone you name
format($format, $local = false, $translate = true) Formatted; UTC unless $local
toSql($local = false, $dbo = null) Database datetime
toISO8601($local = false) 2009-10-06T12:54:37+00:00
toRFC822($local = false) Tue, 06 Oct 2009 12:54:37 +0000
toUnix() Seconds since the epoch
relative($unit = null, $time = null) 3 days ago, 2 weeks ago

format() defaults to UTC, so it is what a machine-readable attribute wants and toLocal() is what the human-readable text beside it wants:

<time datetime="<?php echo Date::of($row->get('created'))->format('Y-m-d\TH:i:s\Z'); ?>">
    <?php echo Date::of($row->get('created'))->toLocal(Lang::txt('DATE_FORMAT_HZ1')); ?>
</time>

format() also translates day and month names by default: D, l, M and F are resolved through Lang::txt() against MONDAY, MON, JANUARY, JANUARY_SHORT and their siblings. Pass false as the third argument where you need the raw PHP output — toSql(), toISO8601() and toRFC822() already do.

relative() renders an age rather than a date, using the JLIB_HTML_DATE_RELATIVE_* keys under a month and "%d %s ago" above it. format('relative') is a shorthand for it.

Moving a date

add($modifier) and subtract($modifier) prefix the string with + or - and pass it to modify(). They are marked deprecated; use modify(), or DateTime's own add()/sub() with a DateInterval, in new code.

$cutoff = Date::of('now')->modify('-30 days')->toSql();

setTimezone($tz) accepts a string as well as a DateTimeZone and records the zone on the object, so subsequent format($f, true) calls use it.

Rewritten and checked against 2.4-main @ 348f0057c2 on 2026-09-10.