declare(strict_types=1);
class DateNormalizer
{
/**
* Explicit formats for ambiguous or non-standard delimiter strings.
* Including dot-separated notation ensures YYYY.MM.DD parses cleanly as a date.
*
* @var string[]
*/
private const EXACT_FORMATS = [
'm/d/Y', // e.g., 05/01/2024 -> 2024-05-01
'm-d-Y', // e.g., 12-31-2023 -> 2023-12-31
'Y.m.d', // e.g., 2024.02.09 -> 2024-02-09
'Y.n.j', // e.g., 2024.2.9 -> 2024-02-09
];
/**
* Converts a supported date string into ISO-8601 YYYY-MM-DD format.
*
* @param string $rawDate Raw uncleaned date input string.
* @return string|null Normalized YYYY-MM-DD date string, or null if invalid/unsupported.
*/
public static function normalizeToIso(string $rawDate): ?string
{
// 1. Sanitize input: Remove surrounding quotes, trim whitespaces, remove hidden zero-width spaces
$cleanDate = self::sanitizeInput($rawDate);
if ($cleanDate === '') {
return null;
}
// 2. Pass 1: Try strict matching for exact structured formats
foreach (self::EXACT_FORMATS as $format) {
$dt = DateTimeImmutable::createFromFormat('!' . $format, $cleanDate);
$errors = DateTimeImmutable::getLastErrors();
if ($dt !== false && ($errors === false || ($errors['error_count'] === 0 && $errors['warning_count'] === 0))) {
return $dt->format('Y-m-d');
}
}
// 3. Pass 2: Fallback to flexible parsing engine
try {
$dt = new DateTimeImmutable($cleanDate);
} catch (\Exception $e) {
return null;
}
// 4. Calendar Strictness Validation: Prevent date rollover (e.g. "Feb 30" -> "Mar 1")
if (!self::isValidCalendarDate($cleanDate, $dt)) {
return null;
}
return $dt->format('Y-m-d');
}
/**
* Clean input string by removing quotes and non-printable control characters.
*/
private static function sanitizeInput(string $input): string
{
$sanitized = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}]/u', '', $input) ?? $input;
return trim($sanitized, " \t\n\r\0\x0B\"'");
}
/**
* Validates if the parsed DateTime Object logically matches the source input components.
* Protects against PHP date overflow bugs (e.g., Feb 30 becoming March 1 or 2).
*/
private static function isValidCalendarDate(string $rawInput, DateTimeImmutable $parsedDate): bool
{
if (preg_match('/\b(\d{1,2})\b/', $rawInput, $matches)) {
$inputDay = (int)$matches[1];
$parsedDay = (int)$parsedDate->format('j');
if ($inputDay !== $parsedDay && $inputDay > 28) {
return false;
}
}
return true;
}
}
// ============================================================================
// Main Execution & Test Suite
// ============================================================================
function runTests(): void
{
$testCases = [
"05/01/2024",
"12-31-2023",
"2024.02.09",
"January 5, 2024",
"2024/09/17",
"2024-09-17",
"25 Dec 2025\u{200B}",
"6 Jan 2025",
"Jan 7, 2025",
"8 January 2025",
"\"7/5/2026\"",
"BadFormat",
"Feb 30, 2024",
"April 31, 2024",
];
echo sprintf("%-25s | %-15s | %s\n", "Input Date String", "Normalized", "Status");
echo str_repeat("-", 55) . "\n";
foreach ($testCases as $test) {
$result = DateNormalizer::normalizeToIso($test);
$displayInput = str_replace("\u{200B}", '', $test);
$status = ($result !== null) ? "VALID" : "INVALID";
echo sprintf("%-25s | %-15s | %s\n", $displayInput, $result ?? "null", $status);
}
}
runTests();
/*
run:
Input Date String | Normalized | Status
-------------------------------------------------------
05/01/2024 | 2024-05-01 | VALID
12-31-2023 | 2023-12-31 | VALID
2024.02.09 | 2024-02-09 | VALID
January 5, 2024 | 2024-01-05 | VALID
2024/09/17 | 2024-09-17 | VALID
2024-09-17 | 2024-09-17 | VALID
25 Dec 2025 | 2025-12-25 | VALID
6 Jan 2025 | 2025-01-06 | VALID
Jan 7, 2025 | 2025-01-07 | VALID
8 January 2025 | 2025-01-08 | VALID
"7/5/2026" | 2026-07-05 | VALID
BadFormat | null | INVALID
Feb 30, 2024 | null | INVALID
April 31, 2024 | null | INVALID
*/