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, scheduled jobs, rate limits, and legal records. Code can format a date nicely and still be wrong if it loses the timezone, parses a loose input, treats a calendar rule like a fixed number of seconds, or makes the current time impossible to control in tests.

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. A mutable date object can be changed through a variable held somewhere else. Immutable dates make each assignment easier to review because a value keeps its meaning after it is created.

The most useful distinction is between an instant and a local wall-clock time. An instant is one exact point on the timeline, such as a token expiry stored in UTC. A local wall-clock time is what a user or business rule says in a place, such as "the appointment starts at 09:00 in Europe/London." Good date code keeps that distinction visible.

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. The timezone is part of the model, not a last-minute formatting choice.

Use named timezones such as Europe/London when the business rule is about a real location. A numeric offset like +01:00 describes one offset from UTC, but it does not describe future daylight saving transitions for a region. For stored instants, UTC is usually the right shared representation. For local schedules, keep the user's or venue's timezone available.

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 shared database columns and API payloads.

UTC storage is not the same as throwing away local meaning. If the event must always happen at 09:00 in London, store the instant for this occurrence and also keep the timezone or scheduling rule needed to create future occurrences. Recurring meetings, opening hours, and billing cycles often need local rules, not only UTC timestamps.

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. Checking getLastErrors() matters because a parsed value can still carry warnings or errors when the input was not a clean match for the intended format.

Do not mix parsing, validation, storage conversion, and display formatting in one anonymous block of controller code. Give the boundary a name, such as parseBookingDate() or bookingStartForStorage(), and test it with valid input, wrong separators, impossible dates, empty strings, and values around daylight saving transitions.

Compare dates as timeline values

PHP can compare DateTimeImmutable objects with <, >, <=, and >=. For expiry checks, this reads naturally.

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, temporary downloads, and cache entries. Decide the equality rule explicitly. If a token expires at 10:00, is it still valid at exactly 10:00? Most expiry code treats expiresAt <= now as expired.

Be careful with identity. === checks whether two object variables refer to the same object instance, not whether two dates represent the same instant. If you need exact equality of the represented instant, compare formatted UTC values or timestamps after deciding the precision your application stores.

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. P1M is not the same kind of rule as P30D; months have different lengths. A subscription renewal date, a report window, and a timeout may need different models even when they all look like date arithmetic.

For machine timeouts such as "cache this for 300 seconds," seconds may be exactly the rule. For billing, appointments, schedules, and due dates, calendar language usually matters more than raw seconds.

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.

DST also creates awkward local times. Some local times may not exist when clocks move forward, and some may be ambiguous when clocks move back. Your application should define what happens for scheduled work, bookings, and reminders at those boundaries. The right answer depends on the business rule, so it belongs in tests and product behavior, not in an undocumented assumption.

Format for the output boundary

Formatting is presentation. Use one format for machine interchange, another for database storage if required by your schema, and another for human display. Do not parse a human display string back into business logic unless that string is explicitly the accepted input format.

PHP example
<?php

declare(strict_types=1);

$instant = new DateTimeImmutable('2026-05-20 08:00', new DateTimeZone('UTC'));
$display = $instant->setTimezone(new DateTimeZone('Europe/London'));

echo $instant->format(DateTimeInterface::ATOM) . PHP_EOL;
echo $display->format('D j M Y, H:i') . PHP_EOL;

// Prints:
// 2026-05-20T08:00:00+00:00
// Wed 20 May 2026, 09:00

The two strings serve different audiences. The first is precise and machine-oriented. The second is human-facing and assumes the display timezone has already been chosen.

Database and API boundaries

Database columns and API payloads need a date policy just as much as PHP code does. Decide whether a field stores a UTC instant, a local date without a time, a local wall-clock time with a timezone, or a display string. Those are different contracts. A birthday, an invoice issue date, a meeting start, and an access-token expiry should not all be modeled the same way just because they can be formatted with Y-m-d.

For shared instants, store and exchange a precise representation consistently. Many applications use UTC and include enough precision for the business rule. For local-only dates, such as a holiday date or birthday, converting to midnight UTC can introduce confusing off-by-one display bugs for users in other timezones. Keep local dates as local dates when the domain does not mean an instant.

APIs should document the accepted format and timezone expectation. If an endpoint accepts a booking start in local venue time, it should also know the venue timezone or receive a timezone field. If an endpoint accepts an expiry instant, it should require an unambiguous instant rather than guessing from the server default timezone. Guessing turns integration bugs into data bugs.

Migrations need the same care. When converting legacy date strings, record the assumed timezone and test representative rows before and after conversion. A one-time script that interprets old local times as UTC can shift every appointment by hours while still producing syntactically valid timestamps.

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. At the application edge, a controller, command handler, or job runner can create the current time once and pass it into domain code. Deeper code receives a value rather than reaching out to the system clock.

Testing date and time code

Date tests should cover more than one normal case. Include invalid input, wrong formats, impossible dates, month ends, leap days when relevant, expiry equality, before-and-after comparisons, timezone conversion, and daylight saving transitions for the timezones your application supports.

Name the rule under test. A test called "adds fourteen days to the invoice issue date" is more useful than a test called "date add works." For recurring local events, include a case around a timezone offset change. For stored instants, compare UTC output. For user display, compare the output after converting to the user's timezone.

Avoid tests that depend on the real current time unless the point is an integration check. Unit tests should pass a fixed $now. That makes failures repeatable and keeps test behavior independent of when the suite runs.

Review checklist

Before approving date code, identify whether each value is an instant, a local wall-clock time, a duration, a calendar period, or a formatted display string. Confirm that user input is parsed with an exact format, timezone names are explicit, stored instants are normalized consistently, and display formatting happens at the output boundary. Look for hidden now calls, timestamp arithmetic for calendar rules, object identity checks, and database fields that mix local offsets without a policy.

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, timezone conversion, 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.

Task: Check whether a token is expired

Write a deterministic expiry helper for token-like values.

Requirements

  • Use declare(strict_types=1);.
  • Write a function that accepts DateTimeImmutable $expiresAt and DateTimeImmutable $now.
  • Treat a token as expired when $expiresAt is equal to or earlier than $now.
  • Print one valid case, one expired-before-now case, and one expires-exactly-now case.
  • Use explicit UTC dates in the examples.
  • Include expected output comments in the PHP code block.
Show solution
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:00', new DateTimeZone('UTC'));

$cases = [
    'future' => new DateTimeImmutable('2026-05-20 10:05:00', new DateTimeZone('UTC')),
    'past' => new DateTimeImmutable('2026-05-20 09:59:59', new DateTimeZone('UTC')),
    'exact' => new DateTimeImmutable('2026-05-20 10:00:00', new DateTimeZone('UTC')),
];

foreach ($cases as $label => $expiresAt) {
    echo $label . ': ' . (isExpired($expiresAt, $now) ? 'expired' : 'valid') . PHP_EOL;
}

// Prints:
// future: valid
// past: expired
// exact: expired

The helper accepts $now instead of creating it internally. That makes the equality rule easy to test and prevents the result from depending on the moment the test happens to run.

Task: Calculate an invoice due date

Build a helper that calculates a due date from an invoice issue date.

Requirements

  • Use declare(strict_types=1);.
  • Write a function that accepts a DateTimeImmutable issue date and an integer number of due days.
  • Reject due-day values less than 1.
  • Add the due days using DateInterval.
  • Print the issue date and due date for a 14-day example.
  • Show one invalid due-days case by catching the exception and printing the message.
  • Include expected output comments in the PHP code block.
Show solution
PHP example
<?php

declare(strict_types=1);

function invoiceDueDate(DateTimeImmutable $issuedAt, int $dueDays): DateTimeImmutable
{
    if ($dueDays < 1) {
        throw new InvalidArgumentException('Due days must be at least 1.');
    }

    return $issuedAt->add(new DateInterval('P' . $dueDays . 'D'));
}

$issuedAt = new DateTimeImmutable('2026-01-31 12:00:00', new DateTimeZone('UTC'));
$dueAt = invoiceDueDate($issuedAt, 14);

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

try {
    invoiceDueDate($issuedAt, 0);
} catch (InvalidArgumentException $exception) {
    echo $exception->getMessage() . PHP_EOL;
}

// Prints:
// Issued: 2026-01-31 12:00
// Due: 2026-02-14 12:00
// Due days must be at least 1.

The helper uses a calendar interval because the business rule is stated in days. It returns a new immutable date and leaves the original issue date unchanged.