data types and standard library

Dates and Times

Dates and times are business rules, not just display values. They affect bookings, subscriptions, invoices, audits, cache expiry, reports, and scheduled jobs.

Use DateTimeImmutable as the default in application code. It returns a new object when you modify it, which prevents accidental changes to a value that another part of the code still expects to use.

Create dates with an explicit timezone

Avoid relying on the server default timezone. Pass the timezone that matches the meaning of the input.

PHP example
<?php

declare(strict_types=1);

$startsAt = new DateTimeImmutable('2026-05-20 09:00', new DateTimeZone('Europe/London'));
$endsAt = $startsAt->modify('+90 minutes');

echo $startsAt->format(DateTimeInterface::ATOM) . PHP_EOL;
echo $endsAt->format(DateTimeInterface::ATOM) . PHP_EOL;

// Prints:
// 2026-05-20T09:00:00+01:00
// 2026-05-20T10:30:00+01:00

The original $startsAt value is unchanged. That makes code easier to review because each variable keeps a stable meaning.

Store instants in UTC

A common production pattern is: accept a user-facing time in the user's timezone, convert it to UTC for storage, then convert it back for display.

PHP example
<?php

declare(strict_types=1);

$userTimezone = new DateTimeZone('Europe/London');
$utc = new DateTimeZone('UTC');

$localStart = new DateTimeImmutable('2026-05-20 09:00', $userTimezone);
$storedStart = $localStart->setTimezone($utc);

echo $storedStart->format(DateTimeInterface::ATOM) . PHP_EOL;

// Prints:
// 2026-05-20T08:00:00+00:00

The event still starts at 09:00 in London, but the stored value represents the same instant in UTC. This avoids mixing local offsets in the database.

Parse user input deliberately

new DateTimeImmutable($input) is flexible, which is not always what you want. For form input, parse the exact format you expect and reject invalid dates.

PHP example
<?php

declare(strict_types=1);

function parseBookingDate(string $input, DateTimeZone $timezone): DateTimeImmutable
{
    $date = DateTimeImmutable::createFromFormat('!Y-m-d H:i', $input, $timezone);
    $errors = DateTimeImmutable::getLastErrors();

    if (!$date || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
        throw new InvalidArgumentException('Booking date must use YYYY-MM-DD HH:MM.');
    }

    return $date;
}

$bookingDate = parseBookingDate('2026-05-20 09:00', new DateTimeZone('Europe/London'));

echo $bookingDate->format('Y-m-d H:i') . PHP_EOL;

// Prints:
// 2026-05-20 09:00

The ! in the format resets unspecified fields instead of inheriting the current date and time. That makes parsing more predictable.

Compare dates as objects

PHP can compare DateTimeImmutable objects with <, >, and ===, but === checks whether they are the same object instance. Use formatted values or timestamps when you need exact equality of the represented instant.

PHP example
<?php

declare(strict_types=1);

$now = new DateTimeImmutable('2026-05-20 10:00', new DateTimeZone('UTC'));
$expiresAt = new DateTimeImmutable('2026-05-20 10:30', new DateTimeZone('UTC'));

if ($expiresAt > $now) {
    echo 'Token is still valid' . PHP_EOL;
}

// Prints:
// Token is still valid

This pattern appears in password resets, email verification links, API tokens, trial periods, and temporary downloads.

Use intervals for business durations

Use DateInterval or modify() for calendar-aware changes. Do not add a fixed number of seconds when the rule is about calendar days, months, or years.

PHP example
<?php

declare(strict_types=1);

$issuedAt = new DateTimeImmutable('2026-01-31 12:00', new DateTimeZone('UTC'));
$dueAt = $issuedAt->add(new DateInterval('P14D'));

echo $dueAt->format('Y-m-d H:i') . PHP_EOL;

// Prints:
// 2026-02-14 12:00

P14D means a period of 14 days. Duration strings are compact, so name variables well and keep the business rule nearby.

Watch daylight saving time

Local timezones can have daylight saving changes. A wall-clock time such as 09:00 Europe/London is not the same kind of value as a UTC timestamp.

PHP example
<?php

declare(strict_types=1);

$timezone = new DateTimeZone('Europe/London');

$beforeChange = new DateTimeImmutable('2026-03-28 09:00', $timezone);
$afterChange = $beforeChange->modify('+2 days');

echo $beforeChange->format(DateTimeInterface::ATOM) . PHP_EOL;
echo $afterChange->format(DateTimeInterface::ATOM) . PHP_EOL;

// Prints:
// 2026-03-28T09:00:00+00:00
// 2026-03-30T09:00:00+01:00

The local appointment time remains 09:00, but the UTC offset changes. This is why timezones must be part of the model, not an afterthought in the view.

Make time testable

Code that calls new DateTimeImmutable('now') deep inside a function is hard to test. Pass the current time in from the edge of the application.

PHP example
<?php

declare(strict_types=1);

function isExpired(DateTimeImmutable $expiresAt, DateTimeImmutable $now): bool
{
    return $expiresAt <= $now;
}

$now = new DateTimeImmutable('2026-05-20 10:00', new DateTimeZone('UTC'));
$expiresAt = new DateTimeImmutable('2026-05-20 09:59', new DateTimeZone('UTC'));

echo isExpired($expiresAt, $now) ? 'expired' : 'valid';
echo PHP_EOL;

// Prints:
// expired

This makes tests deterministic and prevents bugs that only happen at certain times of day.

What to remember

Use DateTimeImmutable, pass explicit timezones, parse user input with an exact format, store shared instants in UTC, and keep local timezone information when the business rule is about local wall-clock time. Dates are easy to make look correct while still being wrong at the boundary, so test the edge cases: invalid input, expiry equality, month ends, and daylight saving transitions.

Practice

Task: Schedule a booking in UTC

Write a small booking helper that accepts a local start time and returns the UTC value that should be stored.

Requirements

  • Use declare(strict_types=1);.
  • Write a function that accepts a date string and a timezone name.
  • Parse the date string using the exact format Y-m-d H:i.
  • Throw an exception if the input is invalid.
  • Convert the valid local time to UTC.
  • Print the stored UTC value for a normal case.
  • Show an invalid input case by catching the exception and printing the message.
  • Include the expected output as comments in the same PHP code block.

The helper should make the boundary clear: users enter local time, but the application stores a UTC instant.

Show solution
PHP example
<?php

declare(strict_types=1);

function bookingStartForStorage(string $localStart, string $timezoneName): DateTimeImmutable
{
    $timezone = new DateTimeZone($timezoneName);
    $date = DateTimeImmutable::createFromFormat('!Y-m-d H:i', $localStart, $timezone);
    $errors = DateTimeImmutable::getLastErrors();

    if (!$date || ($errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) {
        throw new InvalidArgumentException('Booking start must use YYYY-MM-DD HH:MM.');
    }

    return $date->setTimezone(new DateTimeZone('UTC'));
}

$storedStart = bookingStartForStorage('2026-05-20 09:00', 'Europe/London');

echo $storedStart->format(DateTimeInterface::ATOM) . PHP_EOL;

try {
    bookingStartForStorage('20/05/2026 09:00', 'Europe/London');
} catch (InvalidArgumentException $exception) {
    echo $exception->getMessage() . PHP_EOL;
}

// Prints:
// 2026-05-20T08:00:00+00:00
// Booking start must use YYYY-MM-DD HH:MM.

The function parses the user's local wall-clock time, rejects the wrong format, and returns the equivalent UTC instant for storage. The caller can format that value for a database column or API payload.