PHP タイムスタンプと日時を相互変換

PHPの日付関連パーサーによらず自前で変換するにはどうすればいいのか気になったので試しに作ってみたクラスです。

~~~php:date_to_timestamp_class.php
<?php
class DateToTimestamp {
    private $offsetDays;

    public function __construct() {
        // 0001-01-01から1970-01-01までのオフセット日数
        $this->offsetDays = 719163;
    }
    /**
     *  日時 -> UNIXタイムスタンプ変換
     */
    public function dateToTimestamp(...$args) {
        // 引数変数名リスト
        $vars = ['year', 'month', 'day', 'hour', 'minute', 'second'];

        // 第一引数が配列なら各引数に振り分け
        if(isset($args[0]) && is_array($args[0]))
            foreach($vars as $i => $vn) $args[$i] = func_get_arg(0)[$vn] ?? null;

        // 各引数を該当変数へ
        foreach($vars as $i => $vn)
            ${$vn} = $args[$i] ?? (int) date('YmdHis'[$i]);

        try {
            if(array_reduce([$year, $month, $day, $hour, $minute, $second],
                function($a, $b) { return $a += !is_numeric($b);}
            )) {
                throw new Exception(__METHOD__. ': Non-numeric value specified for argument.');
            }
        } catch (Exception $e) {
            return $e->getmessage();
        }

        // 月オーバーフロー補正
        $year += floor(($month - 1) / 12);
        $month = ($month + 11) % 12 + 1;

        // 前年
        $prevYear = $year - 1;

        // 0001-01-01からの日数
        $absoluteDays =
            (365 * $prevYear) + floor($prevYear / 4) - floor($prevYear / 100) + floor($prevYear / 400) +
            array_sum(array_slice($this->monthLastDays($year), 0, $month - 1) ) + $day;

        $timestamp = ($absoluteDays - $this->offsetDays) * 86400 // 1970年起算に戻し日数を秒に変換
                     + $hour * 3600 + $minute * 60 + $second; // 時分秒

        // 時差はタイムゾーン、時期、年代によっても変化し
        // 単純に算出できないので、ここはdateを使用
        $timestamp -= date('Z', $timestamp); // 時差補正
        return $timestamp;
    }

    /**
     *  UNIXタイムスタンプ -> 日時変換
     */
    public function timestampToDate(
        $format    = null,
        $timestamp = null
    ) {
        $timestamp = $timestamp ?? time();
        try {
            if($format === null) {
                throw new Exception(__METHOD__. ': Specify parameters for format string.');
            } elseif(!is_numeric($timestamp)) {
                throw new Exception(__METHOD__. ': Non-numeric value specified for argument.');
            }
        } catch (Exception $e) {
            return $e->getmessage();
        }

        // 時差補正
        $timestampTz = $timestamp + date('Z', $timestamp);
        $tzDiff = date('Z', $timestamp);
        $absTzDiff = abs($tzDiff);

        // 1日内の秒数
        $seconds = ($timestampTz % 86400 + 86400) % 86400;

        // 0001-01-01 00:00:00 を0とした通算日数
        $absoluteDays = (int) (floor($timestampTz / 86400) + $this->offsetDays) - 1;

        // 曜日
        $w = ($absoluteDays + 1) % 7;

        // 年算出用 閏年の区切り配列
        $r = [
            400 => 146097, // 400年 = 36524 * 4 + 1日
            100 =>  36524, // 100年 = 1461 * 25 - 1日
              4 =>   1461, // 4年 = 365 * 4 + 1日
              1 =>    365,
        ];
        $y = [];

        // 通算日数マイナス補正
        $absDaysMinusMagnification = 0;
        if($absoluteDays < 0) {
            $absDaysMinusMagnification = floor(($absoluteDays -1) / $r[400]);
            $absoluteDays = (($absoluteDays % $r[400]) + $r[400]) % $r[400];
            $w = ($absoluteDays + 1) % 7;
        }

        // 配列$yに年を振り分け $absoluteDaysは年内通算日数に
        foreach($r as $k => $v) {
            $y[$k] = floor($absoluteDays / $v) * $k;
            $absoluteDays %= $v;
        }

        // 閏年末日補正
        if($y[1] == 4 || $y[100] == 400) {
            $y[1]--;
            $absoluteDays = 365;
        }

        // 年月日取得
        $year = array_sum($y) + 1;
        $absoluteDays += 1;
        $monthLastDays = $this->monthLastDays($year);

        for($month = 1;
            array_sum(array_slice($monthLastDays, 0, $month)) < $absoluteDays && $month < 13;
            $month++
        );

        // 日
        $day = $absoluteDays - array_sum(array_slice($monthLastDays, 0, $month - 1));

        // 通算日数マイナス補正戻し
        if($absDaysMinusMagnification) {
            $year += 400 * $absDaysMinusMagnification;
        }

        // 時分秒取得
        $hour = (($seconds % 86400) / 3600) % 24;
        $minute = ($seconds % 3600) / 60;
        $second = $seconds % 60;

        // フォーマット文字置換
        $result = preg_replace('/(\w)/', ":$1:", $format);
        $result = preg_replace('/\\\\:(\w):/', "#$1#", $result);
        $result = str_replace(':Y:', sprintf($year < 0 ? '%05d' : '%04d', $year), $result);
        $result = str_replace(':m:', sprintf('%02d', $month), $result);
        $result = str_replace(':d:', sprintf('%02d', $day), $result);
        $result = str_replace(':H:', sprintf('%02d', $hour), $result);
        $result = str_replace(':i:', sprintf('%02d', $minute), $result);
        $result = str_replace(':s:', sprintf('%02d', $second), $result);

        $result = str_replace(':y:', sprintf('%02d', $year % 100), $result);
        $result = str_replace(':n:', $month, $result);
        $result = str_replace(':j:', $day, $result);
        $result = str_replace(':G:', $hour, $result);
        $result = str_replace(':h:', sprintf('%02d', ($hour + 11) % 12 + 1), $result);
        $result = str_replace(':g:', ($hour + 11) % 12 + 1, $result);
        $result = str_replace(':L:', $monthLastDays[1] === 29 ? 1 : 0, $result);

        $result = str_replace(':w:', $w, $result);
        $result = str_replace(':N:', ($w + 6) % 7 + 1, $result);
        $result = str_replace(':z:', $absoluteDays - 1, $result);
        $result = str_replace(':t:', $monthLastDays[$month - 1], $result);

        $result = str_replace(':U:', $timestamp, $result);

        $result = str_replace(':a:', $hour < 12 ? 'am' : 'pm', $result);
        $result = str_replace(':A:', $hour < 12 ? 'AM' : 'PM', $result);
        $result = str_replace(':l:',
            ['Sunday',   'Monday', 'Tuesday', 'Wednesday',
             'Thursday', 'Friday', 'Saturday'][$w], $result);
        $result = str_replace(':D:',
            ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][$w], $result);
        $result = str_replace(':F:',
            ['January',   'February', 'March',    'April',
             'May',       'June',     'July',     'August',
             'September', 'October',  'November', 'December'][$month - 1], $result);
        $result = str_replace(':M:',
            ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug',
             'Sep', 'Oct', 'Nov', 'Dec'][$month - 1], $result);

        // ISO8601
        $result = str_replace(':c:',
            sprintf($year < 0 ? '%05d' : '%04d', $year).
            sprintf('-%02d-%02d', $month, $day). 'T'.
            sprintf('%02d:%02d:%02d', $hour, $minute, $second).
            ($tzDiff < 0 ? '-' : '+').
            sprintf('%02d:%02d', floor($absTzDiff / 3600) % 24, floor($absTzDiff / 60) % 60)
            , $result);

        // タイムゾーン関連等、日時計算を伴わないものはとりあえずdate()の結果を素通し
        $result = preg_replace_callback('/:([eIOPTZ]):/', function($m) { return date($m[1]);}, $result);

        $result = preg_replace('/#(\w)#/', "$1", $result);

        return preg_replace('/:(\w):/', "$1", $result);
    }

    /**
     *  指定した年の各月末日を返す
     */
    private function monthLastDays($year) {
        $year = (($year % 400) + 400) % 400; // マイナス補正
        return [31, (!($year % 4) && ($year % 100) || !($year % 400)) ? 29 : 28,
                31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
    }
}
~~~

素直にDateTimeImmutable等を使えばいいので利用価値はほとんどありませんが、参考に置いておきます。
もっとスマートに計算できる方法があるかもしれませんが、現状これで精一杯。

####動作チェックサンプル####

~~~php:test.php
<?php
$timezone = 'UTC';
//$timezone = 'Asia/Tokyo';
//$timezone = 'Etc/Gmt-9';

require_once('./date_to_timestamp_class.php');
$d = new DateToTimestamp;
date_default_timezone_set($timezone);

$dti = new DateTimeImmutable;
$dti->setTimezone(new DateTimeZone($timezone));

$time = time();
$format = 'Y-m-d H:i:s';
$endtime = $d->dateToTimestamp(-2000,1,1,0,0,0);
do {
    printf("%d %s %s %s\n",
        $time,
        $res1 = $dti->setTimestamp($time)->format($format),
        $res2 = $d->timestampToDate($format, $time),
        $res1 == $res2 ? '-' : '*' // 差異チェック
    );
    $time -= 86400 * 7;
} while($time > $endtime);

for($year = -1000; $year < 2100; $year++){
    for($month = 1; $month <= 12; $month++){
        foreach([-1, 0, 29, 40] as $day){
        printf("%04d-%02d-%02d %s %s %s\n",
            $year,$month,$day,
            $res1 = $dti->setTime(0,0,0)->setDate($year,$month,$day)->getTimestamp(),
            $res2 = $d->dateToTimestamp($year,$month,$day, 0,0,0),
            $res1 == $res2 ? '-' : '*'
        );
        }
    }
}
~~~

時差については、タイムゾーン、年代、時期によっても変化し単純に算出できるものではないので、ここだけは`date`にformat文字**Z**を指定して取得した値で補正しています。
例えばAsia/Tokyoの場合だと、2020年8月現在では日本標準時基準でUTC+09:00、1888年より前では東京地方時基準でのUTC+09:18、1948～1951年のサマータイム期間のUTC+10:00など。