【PHP】DateTimeオブジェクトをもっと使いやすくしてみる

PHP

フレームワークや外部ライブラリ使用禁止のプロジェクトでは、日付計算でCarbonのような超絶便利なものが使えず、PHP組込のDateTimeオブジェクトを使うことになります。

これがまた使いにくいので、もう少し使いやすくしてみましょう。

PHP: DateTime - Manual
PHP is a popular general-purpose scripting language that powers everything from your blog to the most popular websites i...

どこまでやるのか

下の1~3が出来るだけでかなり使い勝手が良くなります。

Carbon程の機能は無理ですが、Carbonが無くても色々と簡単に日付操作ができるようになります。

  • 1.デフォルトタイムゾーンを自動設定できるようにする
  • 2.簡単に日付を進められるようにする
  • 3.簡単に日付を遡れるようにする
  • 4.有効な日付の文字列か判定できるようにする
  • 5.有効な相対日付の文字列か判定できるようにする

前提条件

  • PHP8.2以降
  • Ubuntu上で作業(多分プラットフォームは無関係)

DateTimeのラッパーを作成する

次のような感じで、DateTimeオブジェクトを継承したラッパークラスを作成します。

Carbon自体が DateTimeオブジェクトのラッパーです。

<?php

namespace MyLib;

use DateTime as DateTimeBase;
use DateInterval;
use DateTimeZone;

/**
 * DateTimeラッパー
 * Asia/Tokyo をデフォルトタイムゾーンとすることが目的
 */
class DateTime extends DateTimeBase
{
    public const DEFAULT_TIMEZONE_STRING = "Asia/Tokyo";

    public function __construct(
        public string $datetimeString = "now",
        public DateTimeZone|null $timezone = null,
    ) {
        if (is_null($this->timezone)) {
            $this->timezone = new DateTimeZone(static::DEFAULT_TIMEZONE_STRING);
        }
        parent::__construct($this->datetimeString, $this->timezone);
    }

    /**
     * 相対的に進めた日付のDateTimeオブジェクトを返す
     * サポートする日付と時刻の書式は次のURLの「相対的な書式」を参照
     * https://www.php.net/manual/ja/datetime.formats.php#datetime.formats.relative
     */
    public function forward(string $relativeDateTimeString): self
    {
        $interval = DateInterval::createFromDateString($relativeDateTimeString);
        return $this->add($interval);
    }

    /**
     * 相対的に遡った日付のDateTimeオブジェクトを返す
     * サポートする日付と時刻の書式は次のURLの「相対的な書式」を参照
     * https://www.php.net/manual/ja/datetime.formats.php#datetime.formats.relative
     */
    public function backward(string $relativeDateTimeString): self
    {
        $interval = DateInterval::createFromDateString($relativeDateTimeString);
        return $this->sub($interval);
    }

    public static function isValidDateString(string $dateTimeString): bool
    {
        try {
            new DateTime($dateTimeString);
            return true;
        } catch (\Throwable $e) {
            return false;
        }
    }

    public static function isValidRelativeDateString(string $dateTimeString): bool
    {
        try {
            DateInterval::createFromDateString($dateTimeString);
            return true;
        } catch (\Throwable $e) {
            return false;
        }
    }
}

使ってみましょう

まずは、PHP組込のDateTimeオブジェクトとの違いを見てみましょう。

<?php

require __DIR__ . "/DateTime.php";

use DateTime as DateTimeOrigin;
use MyLib\DateTime;

var_dump(new DateTimeOrigin, new DateTime);

実行すると次のようになります。

PHP組込のDateTimeオブジェクトは日付・タイムゾーンがUTCなのに対し、

ラッパーの方はAsia/Tokyoになっています。

日付を進めたり遡ったり

多分、日付処理で頻繁に使いたい機能だと思います。

日付を進める場合は、forward() メソッド、遡る場合は backward() メソッドを使います。

両メソッドの引数は、DateTimeオブジェクトがサポートする「相対的な書式」の文字列です。

PHP: サポートする日付と時刻の書式 - Manual
PHP is a popular general-purpose scripting language that powers everything from your blog to the most popular websites i...

▼使用例

<?php

require __DIR__ . "/DateTime.php";

use MyLib\DateTime;

echo (new DateTime)->forward("2 days")->format("Y/m/d") . PHP_EOL;
echo (new DateTime)->backward("3 days")->format("Y/m/d") . PHP_EOL;
echo (new DateTime("June 1st, 2024"))->forward("2 months")->format("Y/m/d") . PHP_EOL;
echo (new DateTime("monday this week"))->backward("3 days")->format("Y/m/d") . PHP_EOL;
echo (new DateTime("2024/7/4"))->forward("40 weeks")->format("Y/m/d") . PHP_EOL;
echo (new DateTime("2028-3-1"))->backward("3 days")->format("Y/m/d") . PHP_EOL;

▼実行結果:

特に、最終行の「2028年3月1日」の前日は閏年の「2028年2月29日」なので、その3日前は「2028年2月27日」になります。

DateTimeオブジェクトの方で閏年の判定はしているので、いちいち閏年の計算なんてしなくてもいいんですよね。

どうしても閏年かどうかの判定がしたければ、「IntlGregorianCalendar::isLeapYear(int $year)」を使うか、

または次のように、このDateTimeラッパーにメソッド追加して、「その年の2月末日が29」かどうかを判定すれば済みます。または、format(“L”) で 1なら閏年、0なら閏年でないとすれば済みます。

    public function isLeapYear(int $year): bool
    {
        return (new static("last day of February " . $year))->format("d") === "29";
        //return (new static("last day of February " . $year))->format("L") === "1";
    }

そうすれば、

(new DateTime)->isLeapYear(2028);
(new DateTime)->isLeapYear(2029);

のような使い方ができます。

が、個人的には好まない機能なのでこのDateTimeラッパーには組み込みません。

あと、書いてから気づきましたが、この使い方は違いますね。。本来は

(new DateTime("2028/05/01"))->isLeapYear();

のような感じでしょう。

有効な日付文字列か判定

「isValidDateString()」と「isValidRelativeDateString()」は、日付文字列のバリデーションのようなものですが、設置した目的は DateTimeラッパーで受け付けられる文字列かどうかの判定です。

DBに格納するデータのバリデーションとしては使うものではありません。

▼使用例:

<?php

require __DIR__ . "/DateTime.php";

use MyLib\DateTime;

echo ((new DateTime)->isValidDateString("4th july 2025") ? "Valid" : "Invalid") . PHP_EOL;
echo ((new DateTime)->isValidDateString("おととい") ? "Valid" : "Invalid") . PHP_EOL;

echo ((new DateTime)->isValidRelativeDateString("5 days ago") ? "Valid" : "Invalid") . PHP_EOL;
echo ((new DateTime)->isValidRelativeDateString("2025/06/05") ? "Valid" : "Invalid") . PHP_EOL;

▼実行結果:

因みに、DBに格納するデータのバリデーションでは、次のように正規表現チェックを加える必要があるでしょう。

    public function validateDate(string $key, string $name, mixed $value): void
    {
        if (is_null($value)) {
            $this->setError($key, "{$name}は必須です。");
        } elseif (! is_string($value)) {
            $this->setError($key, "{$name}は文字列でなければなりません。");
        } else {
            $pattern1 = "[0-9]{4}\/(0[1-9]|1[0-2])\/(0[0-9]|[12][0-9]|3[01])";	// ゼロ埋め		スラッシュ区切り
            $pattern2 = "[0-9]{4}\/([1-9]|1[0-2])\/([1-9]|[12][0-9]|3[01])";	// ゼロ埋めなし	スラッシュ区切り
            $pattern3 = "[0-9]{4}\-(0[1-9]|1[0-2])\-(0[0-9]|[12][0-9]|3[01])";	// ゼロ埋め		ハイフン区切り
            $pattern4 = "[0-9]{4}\-([1-9]|1[0-2])\-([1-9]|[12][0-9]|3[01])";	// ゼロ埋めなし	ハイフン区切り
            $pattern = "/^" . $pattern1 . "|" . $pattern2 . "|" . $pattern3 . "|" . $pattern4 . "$/";
            if (! preg_match($pattern, $value)) {
                $this->setError($key, "{$name}の書式は YYYY-mm-dd または YYYY/mm/dd でなければなりません。");
            }
        }
    }

それか、或いはモデル側で受け取るデータはこのDateTimeオブジェクトラッパーだけを許容して、モデル側でデータバインドする際に format() メソッドで整形して渡してしまえばOKでしょう。多分、その方が安全な設計になると思います。

カレンダーでも作ってみますか

せっかくなので、このDateTimeラッパーを使って簡単なアプリケーションでも作ってみましょう。

年、月をそれぞれ数値で指定すると、その月のカレンダーを出力してくれるクラスでも作ってみます。

▼「Calendar.php」

<?php

namespace MyLib;

require __DIR__ . "/DateTime.php";

use MyLib\DateTime;

class Calendar
{
    public static array $weekdays = ["日", "月", "火", "水", "木", "金", "土"];

    public static function getDays(int $year, int $month): array
    {
        $datetimeFirstDay = new DateTime($year . "/" . $month . "/1");
        $datetimeLastDay = (new DateTime($year . "/" . $month . "/1"))->forward("1 month")->backward("1 day");
        $lastDay = (int) $datetimeLastDay->format("d");
        $firstWeekday = (int) $datetimeFirstDay->format("w");
        $lastWeekday = (int) $datetimeLastDay->format("w");

        return array_merge(
            $firstWeekday > 0 ? array_fill(0, $firstWeekday, null) : [],
            range(1, $lastDay),
            $lastWeekday < 6 ? array_fill(0, 6 - $lastWeekday, null) : []
        );
    }

    public static function display(int $year, int $month): void
    {
        echo (new DateTime($year . "/" . $month . "/1"))->format("Y年m月") . PHP_EOL;
        echo " " . implode("  ", static::$weekdays) . PHP_EOL;

        foreach (static::getDays($year, $month) as $i => $day) {
            $dayString = is_null($day) ? "  - " : sprintf(" %2s ", $day);
            echo $dayString . ($i % 7 === 6 ? PHP_EOL : "");
        }
    }
}

▼呼出側「display_calendar.php」

<?php

require __DIR__ . "/Calendar.php";

use MyLib\Calendar;

Calendar::display(2025, 6);
Calendar::display(2025, 7);
Calendar::display(2025, 8);

▼実行結果:

出力箇所をHTML出力にすれば、WEBアプリとしてカレンダー出力ができます。

やっていることは簡単で、出力制御を簡単にするために、

1か月表示用の配列を、日曜スタートで最終週の土曜の分まで作成します。

その月の1日が何曜日かを数値で取得(日曜=0~土曜=6)し、

1日の前の分を null 要素で埋めます。

最終週も同様に、最終日の後ろを土曜の分まで null 要素で埋めます。

あとは、生成された配列をループ表示し、土曜の時だけ改行コードを出力するだけです。

DateTimeラッパーを作って、日付の前後を簡単にできるようにするだけで、

かなり楽に日付処理ができるようになりました。

以上です。

コメント

タイトルとURLをコピーしました