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 能尽早完善对多时区的处理方案。


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

《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
《L01 基础入门》
我们将带你从零开发一个项目并部署到线上,本课程教授 Web 开发中专业、实用的技能,如 Git 工作流、Laravel Mix 前端工作流等。
讨论数量: 21

用户在表单中输入的时间,如 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) 他们的结果都是一样。因为这里取决的是数据库连接的时区。

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

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

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

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

2024-06-07T02:15:42.000000Z

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

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

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

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

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

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

6个月前 评论
yyy123456 6个月前

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

6个月前 评论

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

6个月前 评论

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

6个月前 评论

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

6个月前 评论

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

6个月前 评论

Laravel这方面的设计仍然是优雅的,处理全球跨时区非常爽

1, 直接在中间件里,获取客户端想要的时区,比如cookie、query里带过来的
2, 然后这样设置
3, 把中间件应用到所有路由上

// 设置Laravel App + 数据库时区演示
$timezone = 'Asia/Bangkok';

// 设置Laravel App的时区
config(['app.timezone' => $timezone]);
date_default_timezone_set($timezone);
// MySQL知道的城市不多,获取类似 +08:00 这种格式给MySQL
$databaseTimezone = now()->format("P"); 
// 设置MySQL数据库的时区
config(['database.connections.mysql.timezone' => $databaseTimezone]);
1个月前 评论

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