Laravel 多时区处理困扰:探索多种方案仍无法彻底解决

Laravel 作为一个优雅的框架,开发体验令人愉悦。然而,多时区处理却屡屡让我感到困惑和头疼。我花了好几天尝试各种方案,但问题仍未完全解决。

有没有人遇到多时区处理的需求呢?我搜索了整个中文互联网社区,几乎没有找到相关讨论(包括 learnku.com 本身也没有实现多时区)。一开始,我以为是自己对 Laravel 的时区机制理解不足,经过反复研究,我确认问题确实存在于 Laravel 的实现方式上。

我的需求是:

  • 数据库中的 created_at 等字段要始终以 UTC 存储
  • 用户界面中则以 Asia/Shanghai(上海时间)显示

方案一:修改 config/app.php 中的时区

我将 config/app.php 文件中的 timezone 修改为 Asia/Shanghai,但结果发现:数据库中新建记录的 created_at 字段也变成了上海时间,这违背了我希望数据库字段使用 UTC 的初衷。
结果:方案失败


方案二:保留默认 UTC 时区,界面手动转换

我将 config/app.php 中的 timezone 保持为默认的 UTC。然后,在用户界面中使用:

{{ $post->created_at->setTimezone($client_timezone) }}

这样,created_at 可以成功按用户时区显示。但是问题出现在用户手动输入的字段 expired_at 上。用户在表单中输入的时间,如 2025-01-01 10:30:00(上海时间),Laravel 会原样存入数据库。这不符合我的需求,我希望存储成 UTC 时间
结果:方案失败


方案三:模型中使用 saving 钩子自动转换

基于方案二,我在模型中添加了以下代码,尝试在保存时将用户输入的时间转换为 UTC:

protected $casts = [
    'edit_at' => 'datetime',
    'expired_at' => 'datetime',
];

protected static function booted()
{
    static::saving(function ($model) {
        // 遍历所有声明为 datetime 类型的字段
        foreach ($model->getCasts() as $field => $type) {
            if ($type === 'datetime' && $model->$field) {
                $v = Carbon::createFromFormat(
                    'Y-m-d H:i:s',
                    $model->$field->toDateTimeString(),
                    'Asia/Shanghai'
                )->utc();
                $model->$field = $v;
            }
        }
    });
}

在这种方案下,用户输入的 expired_at 能够成功存储为 UTC 时间,但新的问题出现了:

  • edit_at 字段保存的是 now() 的时间。然而,由于 now() 默认是 UTC 时间,saving 钩子会将它错误地当作上海时间处理并再次转换为 UTC,导致数据库存储的时间比真实 UTC 时间慢了 8 小时

结果:方案失败


附言

// 在 `app/Providers/AppServiceProvider.php` 中执行 `config(['app.timezone' => $client_timezone]);` 可以让 Filament Admin 的数据表格和表单中的日期时间字段正确显示为本地时区的时间。不会影响入库的日期时间字段以及 Blade 模板中的 `{{ $post->some_at }}` 其仍然会以 `config/app.php` 中的时区为准。要在 Blade 模板中展示用户时区的时间,需要手动转换,比如:`{{ $post->some_at->setTimezone($client_timezone) }}`
config(['app.timezone' => $client_timezone]);

另:Laravel vs. Django 的多时区方案

Django 的多时区处理更为优雅。无论 mysite/settings.py 中设置的时区是什么,Django 都会始终以 UTC 存储 datetime 字段,并且在 ORM 读取时自动转换为设置时区的时间。这种设计极大地减少了开发中的时间转换问题。

但是 Laravel 无法做到这样,希望 Laravel 能尽早完善对多时区的处理方案。


希望能在这里获得大家的建议和帮助,谢谢!

《L05 电商实战》
从零开发一个电商项目,功能包括电商后台、商品 & SKU 管理、购物车、订单管理、支付宝支付、微信支付、订单退款流程、优惠券等
《G01 Go 实战入门》
从零开始带你一步步开发一个 Go 博客项目,让你在最短的时间内学会使用 Go 进行编码。项目结构很大程度上参考了 Laravel。
讨论数量: 20

用户在表单中输入的时间,如 2025-01-01 10:30:00(上海时间),Laravel 会原样存入数据库。这不符合我的需求,我希望存储成 UTC 时间。

这是因为实际存在多个时区,一个是 Laravel 应用中的时区,这个时区是 app.timezone 设置的,也是 Laravel 的时区,这个也是 Carbon 获取到的时区(被设置为了 PHP 的默认时区),因为这个步骤在服务提供者注册之前,所以如果你在代码中修改了 app.timezone 也是无效的,只有手动调用 date_default_timezone_set 进行修改,但是看起来似乎不推荐这样做。

除此之外还有一个数据库的时区,如果你的表是 timestamp 的,这个时区才是决定了你存入的时间是按照什么时区存的。

这个以默认的 MySQL 连接为例,在 database.connections.mysql.timezone 中进行配置(接受偏移量字符串)。

如果在数据库连接这里设置了为 +08:00 时区,那么,你写入数据的时候,即使是传递的固定的字符串,MySQL 也可以正确处理。你在不同时区读出来的也是正常的,但是,如果你没有配置这个,那这个时区就是你 MySQL 的时区,如果 MySQL 没有设置,那就是 MySQL 服务器的系统时区了。

换句话说,如果你应用时区(app.timezone)设置了 UTC,以现在是北京时间 2024-10-13 11:30:00 为例,你执行了更新语句

此时数据库的时区(database.connections.mysql.timezone)是 +08:00 ,你同时更新了 expired_at 和 end_at,分别为

expired_at = Carbon::parse('2024-10-13 11:30:00')
end_at = 2024-10-13 11:30:00

那么数据库中的这两列 timestamp 列显示的是什么呢?

expired_at:

  • A. 2024-10-13 11:30:00
  • B. 2024-10-13 19:30:00
  • C. 2024-10-13 03:30:00

end_at:

  • A. 2024-10-13 11:30:00
  • B. 2024-10-13 19:30:00
  • C. 2024-10-13 03:30:00

答案是:A、A。

为什么?因为在你入库的时候,Carbon 也只是简单的 toString 了而已(因为你这里并没有改变时区),因为他就是 UTC 的 2024-10-13 11:30:00(北京时间的 2024-10-13 19:30:00),也就是说,他跟 2024-10-13 11:30:00 没有区别。

而入库的关键点则在于数据库连接的时区,因为你数据库连接使用了 +08:00 时区,那么虽然你存入数据库的是 2024-10-13 11:30:00,但是他是北京时间的 2024-10-13 11:30:00,UTC 时间 2024-10-13 03:30:00

换句话说,你数据库里面存的时间,是取决于你数据库当前连接设置的时区,跟你应用内的时区是没有任何关系的。

那再来猜一猜,如果我把这个时间从数据库取出来,这两个字段分别应该是多少?

expired_at:

  • A. 2024-10-13 11:30:00
  • B. 2024-10-13 19:30:00
  • C. 2024-10-13 03:30:00

end_at:

  • A. 2024-10-13 11:30:00
  • B. 2024-10-13 19:30:00
  • C. 2024-10-13 03:30:00

保持上面的 database.connections.mysql.timezone(+08:00)、app.timezone(UTC) 不变的话,答案还是 A、A,但是如果你把这个数据库连接的时区改为 +00:00 那么取出来的就都是 C 了。

凌乱了吧?其实你要记住,你在你丢给数据库的就是数据库连接当前时区要存的一个时间,而读取的时候,你可以通过变更数据库连接的时区来实现不同的输出。

最简单办法就是把数据库连接和应用内的时区都设置成一致的。

比如,都设置成 UTC,那么用户传入一个北京时间(PRC,+08:00)的时间字符串的时候,你先以北京时间接受这个时间字符串,然后再转为 UTC 时区,最后再入库就好了。

那么现在你上面这个问题而言,你把你的数据库连接时区也设置为 +08:00 就好了。或者你把他转为 UTC 时间,再存入。

最简单的办法,就是存时间戳,前端直接传时间戳给你。

如果要传递时间字符串,还要注意前面提到的

如果前端传给你时间字符串,不论你是直接入库,还是使用 Carbon::parse 解析后入库(不设置时区,使用默认的 app.timezone) 他们的结果都是一样。因为这里取决的是数据库连接的时区。

如果你要让他符合预期,那你在接收到时间字符串之后,就要先按照他的时区进行解析,再转换为数据库连接的时区。

4个月前 评论
fadda (楼主) 4个月前
fadda (楼主) 4个月前
GeorgeKing 4个月前

这个问题其实很简单,前后端约定好,统一用 ISO 8601 的格式传输,我记得这是 Laravel 从 7 还是 8 之后一直默认的格式,当时我们升级到这一版本后也遇到过类似的问题,在官方社区得到的最佳实践就是下面的处理方式:

后端使用 Laravel 默认的时间响应格式,不要在模型做其他转换处理:

2024-06-07T02:15:42.000000Z

前端拿到这样的时间,根据用户所在时区,或者用户设置时区,格式化成对应风格的时间做展示,这样才能够做到真正意义上的国际化和本地化!

用户提交的表单也是同样的道理,前端负责将用户提交的数据从本地化转为 ISO 8601 标准再提交给后端!

4个月前 评论
fadda (楼主) 4个月前
laradocs

让前端处理就好了,后端不是万能的

4个月前 评论
fadda (楼主) 4个月前
yyy123456 4个月前
Kristiano 4个月前
4个月前 评论
fadda (楼主) 4个月前
Marden (作者) 4个月前

正常情况前端处理,如果不怕麻烦。自己写一个转换函数统一处理一下。后端处理也一样,先转时间戳在转UTC,PRC,知道本地存的是哪个时区的就行。百度一下或者问一下AI有很多处理办法,我没记错laravel有不用他的时间格式,你自己写一个setAttribute实现也行,原本框架原来本意就是前端处理这个问题

4个月前 评论
yyy123456 4个月前

模型类全部时间字段加 getParamsAttribute 和 setParamsAttribute?

4个月前 评论

定义一个时区转换的trait,设置需要转换的字段,trait内重写model的getAttribute、setAttribute、attributesToArray,在这里面做转换,需要转换的模型use 这个trait

4个月前 评论

时间国际化的需求,数据库时间字段建议存时间戳,因为时间戳对于全球各个时区来说是唯一的,渲染的时候只需要用时区转换对应时区时间即可,也可以用开源的时间国际化的包进处理,封装公共方法即可。

4个月前 评论

多时区入库不是应该存时间戳吗

4个月前 评论

客户端可以选、提交UTC时间,显示本地时间

4个月前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!