Rejecting Dates That Only Look Like Dates

PHP's date parser doesn't fail on 20241312 — it rolls month thirteen into January 2025. A parse that succeeds is not evidence of a real date, so Photo Sweep formats every result back and requires a match.

Rejecting Dates That Only Look Like Dates

DateTime::createFromFormat('Ymd', '20241312', $timezone) does not throw. It does not warn. It returns a valid DateTime object pointing at January 12th, 2025 — month thirteen rolled over the year boundary as if that were a reasonable thing to want.

createFromFormat succeeds on 20241312 by rolling it into 2025-01-12; only a round-trip check catches it
createFromFormat succeeds on 20241312 by rolling it into 2025-01-12; only a round-trip check catches it

The rule: a successful parse is not evidence of a real date — only a round-trip match is.

Where this bites

Photo Sweep's filename-date resolver pulls eight-digit runs out of filenames looking for patterns like IMG_20240712_140325.jpg. Any eight consecutive digits in a filename is a candidate. Most of the time that's genuinely a date. Sometimes it's a serial number, a device ID embedded by some obscure camera firmware, or a WhatsApp forward that renamed the file to something that happens to be eight digits. Feed one of those into DateTime::createFromFormat('Ymd', …) and PHP will cheerfully hand back a timestamp for a photo that was never taken on that day, or ever.

The failure mode isn't an exception to catch — it's a wrong answer that looks exactly like a right one. If that timestamp gets bucketed into a month, the month grid grows a phantom entry: one photo, no others near it, no explanation. The user cannot fix it because there's nothing wrong to point at; the app simply believed a lie.

The fix is a round trip, not a try/catch

private function parseStrict(string $format, string $value, \DateTimeZone $timezone): ?int {
    $parsed = \DateTime::createFromFormat($format, $value, $timezone);
    if ($parsed === false) {
        return null;
    }

    if ($parsed->format(ltrim($format, '!')) !== $value) {
        return null;
    }

    $timestamp = $parsed->getTimestamp();
    return $this->isPlausible($timestamp) ? $timestamp : null;
}

Format the parsed result back into the same format it was parsed from, and compare it against the original string. 2025-01-12 formatted back as Ymd produces 20250112, which does not equal the input 20241312 — rejected. A genuine date round-trips cleanly by definition; a rollover can't, because the value that comes back describes a different calendar day than the one that went in.

This is stricter than it sounds. PHP's ! prefix on a format string (used here as '!Ymd') already suppresses filling in "now" for unspecified components, which handles one class of silent misparse. It says nothing about rollover. The round-trip check is the only thing that does.

A second gate: plausibility

Even a value that round-trips correctly can be a real date pointing at something absurd — a filename that happens to contain a valid calendar date in 1970, say, from a camera with a dead clock. isPlausible() adds a floor and ceiling:

private const EARLIEST_PLAUSIBLE = 504921600; // 1986-01-01
private const FUTURE_TOLERANCE = 86400 * 2;

private function isPlausible(int $timestamp): bool {
    return $timestamp >= self::EARLIEST_PLAUSIBLE
        && $timestamp <= (time() + self::FUTURE_TOLERANCE);
}

1986 as a floor isn't arbitrary sentimentality about early digital cameras — it's a floor low enough to never reject a real photo while still catching a misparsed value that lands near the Unix epoch. The two-day future tolerance absorbs clock skew between a camera and the server without letting a badly-set device clock invent a month that hasn't happened yet.

The general shape of the bug

This is a category of mistake that shows up anywhere a permissive parser meets untrusted input: the parser's job is to accept as much as it can, and your job is the opposite — to reject everything that isn't actually what it claims to be. strtotime(), createFromFormat(), and most date libraries in most languages are built for the first job. Whether a parse "succeeded" is the wrong question. Whether the value it produced still describes the string you gave it is the right one, and it's one extra line to check.

Series: Photo Sweep.