Skip to content
Snippets Groups Projects

Laravel 資料庫時區問題分享

  • Clone with SSH
  • Clone with HTTPS
  • Embed
  • Share
    The snippet can be accessed without any authentication.
    Authored by Shih-Tse Chang

    簡介:儲存 ISO 8601 字串表示的時間會遺失時區資訊。

    開發 Salary 時發現一個神奇的現象,跟大家分享。

    案例

    • config('app.timezone')Asia/Taipei (UTC+8)
    • 有一個 Post model
      • 其中有個 Datetime 欄位 published_at
      • 在 model 裡有設定 protected $casts = ['published_at' => 'datetime'];

    如果前端要做新增 Post 的動作:

    $post = new Post();
    $post->published_at = '2023-03-30T22:00:00.000Z';  // ISO 8601 UTC+0
    $post->save();
    • 原本想儲存的
      • 2023-03-30T22:00:00.000Z == 2023-03-30 22:00:00 (UTC+0) == 2023-03-31 06:00:00 (UTC+8)
    • 資料庫裡儲存的 published_at 資料將會是
      • 2023-03-30 22:00:00
    • PHP 轉成 JSON 噴給前端的 published_at 則會是
      • 2023-03-30T16:00:00.000Z

    真神奇

    分析

    首先,在 CC 的使用上,資料庫是不儲存時區的,在資料庫儲存的時間的時區都是跟著 config('app.timezone'),所以都是 UTC+8 (admission 系統除外)。
    當我們在塞資料進資料庫的時候,Laravel 會直接將提供的字串轉為 Carbon 物件,然後按照Y-m-d H:i:s的格式儲存進資料庫。因此時區資訊直接被捨去了、也未對時區做轉換。

    從資料庫拿取資料的時候 Laravel 又很聰明的認為資料庫儲存的 Datetime 會是 config('app.timezone') 的時區。

    解法

    寫了一個 custom cast: LocalDatetime。

    當開發者 assign attribute value 時,會解析 value 為 Carbon 或 CarbonImmutable 物件,並轉換時區到 config('app.timezone') 的時區。

    在資料庫儲存時直接捨去時區後剩下的時間將會是 config('app.timezone') 的時區時間,且 serialize to JSON 時仍會是 ISO8601 Z 結尾的字串(UTC+0)。

    參考資料

    Edited
    LocalDatetime.php 1.52 KiB
    <?php
    
    namespace App\Casts;
    
    use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
    use Illuminate\Support\Facades\Date;
    
    class LocalDatetime implements CastsAttributes
    {
        /**
         * @param bool $time Indicate whether the value is with time
         * @param bool $immutable Indicate whether to use immutable instance
         */
        public function __construct(public bool $time = true, public bool $immutable = false)
        {
        }
    
    
        /**
         * Cast the given value.
         *
         * @param  \Illuminate\Database\Eloquent\Model  $model
         * @param  string  $key
         * @param  mixed  $value
         * @param  array  $attributes
         * @return mixed
         */
        public function get($model, string $key, $value, array $attributes): mixed
        {
            return $this->cast($value);
        }
    
        /**
         * Prepare the given value for storage.
         *
         * @param  \Illuminate\Database\Eloquent\Model  $model
         * @param  string  $key
         * @param  mixed  $value
         * @param  array  $attributes
         * @return mixed
         */
        public function set($model, string $key, $value, array $attributes)
        {
            return $this->cast($value);
        }
    
        protected function cast($value)
        {
            if (is_string($value)) {
                $value = Date::parse($value);
            }
    
            $value = Date::instance($value)->setTimezone(config('app.timezone'));
    
            if (! $this->time) {
                $value = $value->startOfDay();
            }
    
            if ($this->immutable) {
                $value = $value->toImmutable();
            }
    
            return $value;
        }
    }
    0% Loading or .
    You are about to add 0 people to the discussion. Proceed with caution.
    Finish editing this message first!
    Please register or to comment