In PHP, how does DateTimeImmutable::createFromFormat() parse input, and why can a date like 2026-02-30 or a format without '!' give a surprising result?
answer
- explicit format, not a guessing parser
- returns false on failure
- unparsed fields take the current time
- ! or | resets to the epoch
- overflow is only a warning
basics
~20 screateFromFormat() parses against an explicit format and returns false when it cannot. Fields the format omits take the current time unless it starts with ! or ends with |, and February 30 rolls into March with only a warning.
solid answer
~50 s`DateTimeImmutable::createFromFormat($format, $string, $zone)` reads the string exactly as the format says (`Y-m-d`, `d/m/Y H:i`) instead of guessing like the constructor does, and returns `false` on failure rather than throwing. Two surprises catch people. First, any field the format does not mention is filled from the **current** date and time, so `createFromFormat('Y-m-d', '2026-03-01')` carries the current time of day; start the format with `!` or end it with `|` to reset unparsed fields to the Unix epoch (midnight). Second, out-of-range values overflow: `2026-02-30` becomes 2 March 2026, and the only trace is the warning "The parsed date was invalid" in `DateTimeImmutable::getLastErrors()`. For strict validation, check that the result is not `false`, that `getLastErrors()` returns `false` (it does so when there is nothing to report, since PHP 8.2), or that formatting the result gives back the input.
code
php · 18 lines<?php
declare(strict_types=1);
function parseDate(string $input): ?DateTimeImmutable
{
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $input, new DateTimeZone('UTC'));
if ($date === false || DateTimeImmutable::getLastErrors() !== false) {
return null; // no match, or overflow warning
}
return $date;
}
var_dump(parseDate('2026-03-01')?->format(DATE_ATOM)); // 2026-03-01T00:00:00+00:00
var_dump(parseDate('2026-02-30')); // NULL: "The parsed date was invalid"
var_dump(parseDate('01/03/2026')); // NULL: separators do not match
$loose = DateTimeImmutable::createFromFormat('Y-m-d', '2026-03-01');
echo $loose->format('H:i:s'), "\n"; // the current time, not 00:00:00go deeper
Recall that createFromFormat() takes an explicit format like Y-m-d, returns false on failure, and that its result must be checked before use.
Explain why unparsed fields take the current time, how ! and | reset them, and how an impossible day overflows into the next month with only a warning.
Build one strict parsing helper for the code base, with a leading !, false checks, getLastErrors() and an explicit zone, and ban free-form parsing of user input.
Decide where external date formats are validated and normalised, so the rest of the system only ever sees well-formed, zoned instants.
## Two ways to turn text into a date PHP has two parsers: - The **constructor** (`new DateTimeImmutable($text)`) and `strtotime()` accept many free-form formats, including relative ones like `next monday` or `+1 week`. They guess, and some guesses depend on the separator: `01/02/2026` is read as American month/day, while `01-02-2026` is read as day-month-year. - **`createFromFormat($format, $datetime, ?DateTimeZone $timezone = null)`** parses against an explicit format. It is the right tool for user input and external files, where you know the layout and want anything else rejected. The same format letters are used by `format()`, so a round trip is easy to write: `Y` four-digit year, `m` month with leading zero, `d` day with leading zero, `H` hour, `i` minutes, `s` seconds. ## What it returns | Situation | Result | |---|---| | String matches the format | a new `DateTimeImmutable` | | String does not match (wrong separator, trailing text) | `false` | | String matches, but a value is out of range | an object with the overflowed date, plus a warning in `getLastErrors()` | | String contains a NUL byte | `ValueError` is thrown | The `false` return is easy to miss: calling `->format()` on it throws an `Error`. Always check the result. ## Surprise 1: unparsed fields take the current time The manual is explicit: all fields are initialised with the **current date and time**, and the format only overwrites the ones it parses. So `createFromFormat('Y-m-d', '2026-03-01')` returns 1 March 2026 **at whatever time it is now**. Two birthdays parsed a second apart are not equal, and comparisons against midnight fail. The fix is one character: 1. `!` at the **start** of the format resets every field to the Unix epoch (`1970-01-01 00:00:00`) before parsing. 2. `|` at the **end** resets only the fields that were not parsed. `createFromFormat('!Y-m-d', '2026-03-01')` and `createFromFormat('Y-m-d|', '2026-03-01')` both give midnight. ## Surprise 2: impossible dates overflow The parser checks that each field has the right shape, not that the date exists. `createFromFormat('Y-m-d', '2026-02-30')` returns **2 March 2026**, because 30 February 2026 is two days after 28 February. The only trace is a warning, `The parsed date was invalid`, recorded in the parser's last errors. `DateTimeImmutable::getLastErrors()` returns an array with `warning_count`, `warnings`, `error_count` and `errors`. **Since PHP 8.2** it returns `false` when there are neither warnings nor errors; before 8.2 it always returned the array, so old checks like `$errors['warning_count'] > 0` now need to handle `false` first. ## Format characters worth knowing | Character | Parses | |---|---| | `Y` / `y` | four-digit / two-digit year | | `m` / `n` | month with / without leading zero | | `d` / `j` | day with / without leading zero | | `H` / `G` | 24-hour hour with / without leading zero | | `i`, `s` | minutes, seconds | | `U` | seconds since the Unix epoch | | `e`, `P`, `O`, `T` | zone identifier, offset or abbreviation | | `!` / `\|` | reset all fields / reset unparsed fields | | `+` | allow trailing data, reported as a warning | | `*` | skip bytes until the next separator or digit | The `+` character is the opposite of what a validator wants: it turns trailing garbage into a warning instead of a failure, so use it only when you deliberately ignore a suffix, and still check `getLastErrors()`. ## A strict parsing recipe - Use `createFromFormat()` with a leading `!`. - Reject `false`. - Reject any result when `getLastErrors()` is not `false`. - Optionally compare `$date->format($format)` with the input, which also catches overflow. - Pass the intended `DateTimeZone` explicitly unless the format parses a zone (`e`, `T`, `P`, `O`). ## Version notes - PHP 8.2 changed `getLastErrors()` to return `false` when there is nothing to report. - PHP 8.3 made the **constructor** and `modify()` throw `DateMalformedStringException` for unparseable strings; `createFromFormat()` still signals failure by returning `false`.
- Why does createFromFormat('Y-m-d', ...) make equality checks between two parsed dates flaky?Without `!` or `|`, the hour, minute, second and microsecond come from the current time at the moment of parsing. Two dates parsed from the same string a few microseconds apart differ, so `==` fails. Adding `!` at the start of the format zeroes every unparsed field and makes the result deterministic.
- Why prefer createFromFormat() over new DateTimeImmutable($input) for a form field?The constructor accepts a huge range of free-form and relative formats, so input such as `tomorrow` or `01/02/2026` parses successfully with a meaning the user may not intend. `createFromFormat()` accepts only the layout you declare, returns `false` for anything else, and reports overflow through `getLastErrors()`.
saying these in an interview costs you the question
- createFromFormat() throws an exception when the input does not match
- Fields missing from the format default to midnight
- 2026-02-30 makes createFromFormat() return false
- getLastErrors() always returns an array
- The constructor is stricter than createFromFormat()