How to convert multiple date formats to YYYY-MM-DD in PHP

1 Answer

0 votes
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

*/

 



answered Sep 24 by avibootz
...