Common Date/Time Pitfalls and How to Avoid Them

Recognize and avoid the most common date/time mistakes: ambiguous date formats, misuse of raw timestamps, DateTime serialization gotchas, poor choice of database column types, and untestable time-dependent code.

Date and time handling is a minefield of small mistakes that each seem harmless in isolation but compound into real production bugs: a date format that two people read differently, a DateTime object that silently turns into an empty object when JSON-encoded, a database column that quietly discards timezone information, and test suites that pass on Tuesday and fail on the last day of the month. This lesson is a tour of the sharpest of these pitfalls in PHP, with the concrete fix for each.

Ambiguous Date Formats

The string '01/02/2024' means January 2nd in the US convention (month/day/year) and February 1st almost everywhere else (day/month/year). Neither reading is 'more correct' — it depends entirely on locale convention, which is exactly why any date format that isn't self-describing is a bug waiting to happen the moment it crosses a boundary between systems, locales, or developers who assume different conventions.

The fix is to always use an unambiguous, locale-independent format wherever a date crosses a system boundary: ISO 8601, i.e. YYYY-MM-DD for dates and YYYY-MM-DDTHH:MM:SSZ (or with an explicit offset) for date-times. The year-first, zero-padded, largest-to-smallest structure of ISO 8601 has no ambiguous reading and also happens to sort correctly as a plain string. Reserve locale-specific formats like '15 March 2024' or '03/15/2024' strictly for final human-facing display, generated with an explicit locale/format choice, never for storage, APIs, or parsing.

• '01/02/2024' is genuinely ambiguous — Jan 2 (US) or Feb 1 (rest of world)? • Use ISO 8601 (YYYY-MM-DD, or full DATE_ATOM) for anything crossing a system boundary. • ISO 8601 strings also sort correctly lexicographically, as a useful side benefit. • Save locale-formatted strings ('15 March 2024') strictly for final human display.

Timestamps vs DateTime Objects

PHP's time() returns a plain integer Unix timestamp — seconds since 1970-01-01 UTC. Integer timestamps are simple, compact, and inherently timezone-free (they always represent the same UTC instant regardless of how they're later displayed), which makes them attractive for quick comparisons or storage. But they lose expressiveness: you can't ask a raw integer for its weekday, add a calendar month to it correctly (a month isn't a fixed number of seconds), or carry along a display timezone.

DateTime/DateTimeImmutable objects wrap that same fundamental instant with calendar-aware arithmetic and explicit timezone handling on top, at the cost of being heavier objects rather than a single scalar. A reasonable rule of thumb: use time()/integer timestamps for lightweight internal bookkeeping (log timing, simple 'is this cache entry stale' checks with fixed-second thresholds), and use DateTime objects wherever you need calendar-correct arithmetic, formatting, or timezone conversion — which in application code is most of the time.

• time() — a plain integer, seconds since the Unix epoch (1970-01-01 UTC), always timezone-neutral. • Integer timestamps are simple but can't do calendar-aware math (e.g. 'add one month') correctly on their own. • $dt->getTimestamp() converts a DateTime object back to a plain integer when you need one. • Prefer DateTime objects for anything involving calendar arithmetic, formatting, or timezone display.

Serialization Gotchas

A very common surprise: calling json_encode() directly on a DateTime or DateTimeImmutable object does not produce a simple date string. Because these classes expose their internal state as public-looking properties (date, timezone_type, timezone), the default JSON encoding dumps that whole internal representation as a JSON object, e.g. {"date":"2024-03-15 14:30:00.000000","timezone_type":3,"timezone":"UTC"} — almost never what an API consumer wants, and a format that can change between PHP versions.

The fix is to never json_encode() a DateTime object directly. Either format it explicitly before encoding ($data['created_at'] = $dt->format(DATE_ATOM);), or implement PHP's JsonSerializable interface on any DTO/model class that wraps a DateTime, returning the formatted string from jsonSerialize() so json_encode() calls it automatically. The same caution applies to serialize()/unserialize() and to any framework's default object-to-array casting — always check what a date field actually produces before shipping it across a boundary.

• json_encode($dateTimeObject) dumps internal state, not a clean string — almost never what you want. • Always explicitly format before encoding: json_encode(['at' => $dt->format(DATE_ATOM)]). • Or implement JsonSerializable::jsonSerialize() on wrapper classes so encoding is automatic and consistent. • The same caution applies to serialize()/unserialize() and framework auto-casting.

Database Column Types

Relational databases offer several ways to store a moment in time, and picking the wrong one silently loses information. MySQL's DATETIME stores a calendar date and time with no timezone attached at all — it's just whatever values you gave it, with no idea what zone they're in, so if the application's timezone assumption ever drifts, existing rows are now silently wrong relative to the new assumption. MySQL's TIMESTAMP type, by contrast, is stored internally as UTC and converted to/from the connection's session timezone on every read/write — convenient, but it has a much narrower valid range (it overflows in 2038 on many systems) and its automatic conversion can itself become a source of confusion if the session timezone isn't what you expect.

PostgreSQL's timestamptz ("timestamp with time zone", despite the name, doesn't actually store a timezone — it normalizes and stores in UTC and converts on display based on the session setting) is generally the safer, more predictable choice there, versus plain timestamp which behaves like MySQL's DATETIME (no timezone awareness at all).

A robust, portable pattern that sidesteps most of these per-database quirks: store an explicit UTC value using a type the database treats consistently (an integer Unix timestamp column, or a datetime/timestamp column with an ironclad application-level convention that it always holds UTC), and never rely on a column's implicit timezone conversion behavior without having verified exactly what it does for your specific database and configuration.

• MySQL DATETIME: no timezone awareness at all — just the literal values you stored. • MySQL TIMESTAMP: stored as UTC, converted using session timezone — but has a 2038-range limitation. • PostgreSQL timestamptz: normalizes to UTC internally, converts for display — generally the safer default there. • Safest portable pattern: always store UTC explicitly by convention, and verify your driver/DB's actual conversion behavior rather than assuming it.

Testing Time-Dependent Code

Code that calls new DateTime('now') or time() directly, scattered throughout business logic, is notoriously hard to test reliably: a test that asserts 'this discount expires in exactly 3600 seconds' can flake depending on exactly when the test runner happens to execute, and a test written around 'today' can break every year, or worse, once a year around a leap day or a DST boundary that nobody thought to simulate. This class of bug is sometimes called '2038 problem'-adjacent, but the more common everyday version is just: tests that quietly depend on the wall clock at the moment they happen to run.

The standard fix is to never call 'now' directly deep inside business logic. Instead, inject the current time as a dependency — a simple ClockInterface (or PHP's PSR-20-style clock abstraction) with a single now(): DateTimeImmutable method, implemented by a SystemClock in production and a FrozenClock/FixedClock in tests that always returns a predetermined instant. This makes time-dependent logic fully deterministic and repeatable in tests: you can construct a FrozenClock set to exactly the DST fall-back boundary, or to February 29th on a leap year, and assert your code handles it correctly every single time the test runs, rather than only when the calendar happens to cooperate.

• Calling new DateTime('now') deep inside business logic makes that logic hard to test deterministically. • Inject a clock abstraction (e.g. a ClockInterface with now(): DateTimeImmutable) instead of calling 'now' directly. • Use a SystemClock in production and a FrozenClock/fixed-time implementation in tests. • A frozen clock lets you deterministically test edge cases: leap days, DST boundaries, month-end rollovers.

A minimal injectable clock

Business logic depends on ClockInterface, never on 'new DateTime(\'now\')' directly. Tests construct a FrozenClock with an exact instant, including edge cases like DST boundaries.

interface ClockInterface {
    public function now(): DateTimeImmutable;
}

final class SystemClock implements ClockInterface {
    public function now(): DateTimeImmutable {
        return new DateTimeImmutable('now', new DateTimeZone('UTC'));
    }
}

final class FrozenClock implements ClockInterface {
    public function __construct(private DateTimeImmutable $fixed) {}
    public function now(): DateTimeImmutable {
        return $this->fixed;
    }
}

Code Examples

The ambiguous slash-separated format is never used for storage or transmission; the ISO 8601 string is parsed directly and unambiguously, then formatted for human display only at the very end. Expected result: Thursday, February 1, 2024 $clear = '2024-02-01';

Common mistake: Storing or exchanging dates as 'MM/DD/YYYY' or 'DD/MM/YYYY' strings between systems that may assume different conventions.

Implementing JsonSerializable::jsonSerialize() guarantees the DateTime field is always emitted as a clean ISO 8601 string, regardless of where json_encode() is called on this object, rather than leaking DateTime's internal representation. Expected result: {"id":42,"placed_at":"2024-03-15T14:30:00+00:00"}

'placed_at' => $this->placedAt->format(DATE_ATOM), Common mistake: Calling json_encode() on a raw DateTime/DateTimeImmutable object (or a DTO that doesn't implement JsonSerializable) and shipping its internal representation to an API consumer.

Because DiscountService depends on ClockInterface rather than calling 'new DateTime(\'now\')' internally, the test can pin 'now' to an exact instant — including deliberately picking a DST-boundary date to make sure that edge case is covered — and the assertion is 100% reproducible regardless of when the test suite actually executes. Expected result: bool(false) public function isExpired(DateTimeImmutable $expiresAt): bool {

Common mistake: Writing return new DateTime('now') > $expiresAt; directly inside the service, which makes the exact same logic untestable without manipulating the real system clock.

<?php
// Ambiguous: is this Jan 2 or Feb 1? Depends entirely on reader assumption.
$ambiguous = '01/02/2024';

// Unambiguous ISO 8601 — always year-month-day, no room for misreading
$clear = '2024-02-01';
$parsed = new DateTimeImmutable($clear);
echo $parsed->format('l, F j, Y') . PHP_EOL; // Thursday, February 1, 2024
<?php
final class Order implements JsonSerializable {
    public function __construct(
        private int $id,
        private DateTimeImmutable $placedAt
    ) {}

    public function jsonSerialize(): array {
        return [
            'id' => $this->id,
            'placed_at' => $this->placedAt->format(DATE_ATOM),
        ];
    }
}

$order = new Order(42, new DateTimeImmutable('2024-03-15 14:30:00', new DateTimeZone('UTC')));
echo json_encode($order) . PHP_EOL;
// {"id":42,"placed_at":"2024-03-15T14:30:00+00:00"}

// Without JsonSerializable, json_encode($order->placedAt) directly would instead dump:
// {"date":"2024-03-15 14:30:00.000000","timezone_type":3,"timezone":"UTC"}
<?php
final class DiscountService {
    public function __construct(private ClockInterface $clock) {}

    public function isExpired(DateTimeImmutable $expiresAt): bool {
        return $this->clock->now() > $expiresAt;
    }
}

// In a test: no flakiness, no dependency on when the test happens to run
$frozen = new FrozenClock(new DateTimeImmutable('2024-11-03 01:30:00', new DateTimeZone('UTC')));
$service = new DiscountService($frozen);

$expiresAt = new DateTimeImmutable('2024-11-03 02:00:00', new DateTimeZone('UTC'));
var_dump($service->isExpired($expiresAt)); // bool(false) — deterministic, every run

Important Rules

Never use locale-ambiguous date formats ('MM/DD/YYYY') for storage, APIs, or parsing — use ISO 8601.

Use integer timestamps for lightweight internal bookkeeping; use DateTime objects for calendar-aware logic.

Never json_encode() a DateTime object directly — format it explicitly or implement JsonSerializable.

Know exactly what timezone behavior your database column type provides — don't assume DATETIME and TIMESTAMP behave the same.

Inject a clock abstraction rather than calling 'now' directly inside business logic, so tests can freeze time deterministically.

Payment processing and audit logs

A payment gateway integration is a good example of nearly every pitfall in this lesson at once: it must store the transaction instant as UTC (never a locale-ambiguous string), expose it in API responses as ISO 8601 via explicit formatting or JsonSerializable (never a raw serialized DateTime), pick a database column type it fully understands the timezone behavior of, and have its 'is this transaction within the refund window' logic backed by an injectable clock so QA can test end-of-refund-window behavior deterministically instead of waiting for a real deadline to arrive.

class SubscriptionService { public function isActive(DateTimeImmutable $renewsAt): bool { return new DateTimeImmutable('now') < $renewsAt; // calls real 'now' directly } } // Test result depends on exactly when the test suite happens to run — flaky near boundaries.

class SubscriptionService { public function __construct(private ClockInterface $clock) {} public function isActive(DateTimeImmutable $renewsAt): bool { return $this->clock->now() < $renewsAt; } } // Tests inject a FrozenClock with a fixed instant — fully deterministic, including edge cases.

Calling 'now' directly inside business logic ties the code's behavior to the exact wall-clock moment a test happens to execute, producing intermittent failures near time boundaries and making it impossible to deliberately test edge cases like a leap day or a DST transition. Injecting a clock makes 'now' a controllable, testable input.

Why is the string '01/02/2024' considered ambiguous? A. Because it's missing the time component B. Because different locale conventions read it as either January 2 or February 1 C. Because PHP cannot parse slash-separated dates at all D. Because it doesn't include a timezone

B. Because different locale conventions read it as either January 2 or February 1 — Whether the format is month/day/year or day/month/year is a locale convention, not something the string itself specifies, so '01/02/2024' has two equally plausible readings depending on the reader's assumption.

What happens if you call json_encode() directly on a DateTime object? A. It throws a TypeError B. It produces a clean ISO 8601 string automatically C. It dumps the object's internal representation (date, timezone_type, timezone) as a JSON object D. It returns null

C. It dumps the object's internal representation (date, timezone_type, timezone) as a JSON object — DateTime/DateTimeImmutable don't implement JsonSerializable with a clean string output by default, so json_encode() serializes their internal public-like properties instead — rarely what an API consumer wants.

Why does injecting a ClockInterface make time-dependent code easier to test? A. It makes the code run faster B. It removes the need for any date/time logic at all C. It lets tests supply a fixed, predetermined 'now', making assertions deterministic and able to target specific edge cases like DST boundaries D. It automatically converts all times to UTC

C. It lets tests supply a fixed, predetermined 'now', making assertions deterministic and able to target specific edge cases like DST boundaries — A FrozenClock implementation of ClockInterface always returns the same instant, so a test can pin 'now' to any exact moment it wants — including tricky edge cases — instead of depending on the real wall clock at the moment the test happens to run.

Refactor a class PasswordResetToken that currently calls new DateTime('now') directly inside an isExpired(): bool method so that it instead accepts a ClockInterface in its constructor and uses that for the 'now' comparison, making it testable with a FrozenClock.

final class PasswordResetToken { public function __construct( private ClockInterface $clock, private DateTimeImmutable $expiresAt ) {} public function isExpired(): bool { return $this->clock->now() > $this->expiresAt; } }

Store the ClockInterface as a constructor-promoted property, and replace every direct 'now' call in the class body with $this->clock->now().