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 能尽早完善对多时区的处理方案。
希望能在这里获得大家的建议和帮助,谢谢!
这是因为实际存在多个时区,一个是 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,分别为
那么数据库中的这两列 timestamp 列显示的是什么呢?
expired_at:
end_at:
答案是: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:
end_at:
保持上面的 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) 他们的结果都是一样。因为这里取决的是数据库连接的时区。
如果你要让他符合预期,那你在接收到时间字符串之后,就要先按照他的时区进行解析,再转换为数据库连接的时区。
这个问题其实很简单,前后端约定好,统一用 ISO 8601 的格式传输,我记得这是 Laravel 从 7 还是 8 之后一直默认的格式,当时我们升级到这一版本后也遇到过类似的问题,在官方社区得到的最佳实践就是下面的处理方式:
后端使用 Laravel 默认的时间响应格式,不要在模型做其他转换处理:
前端拿到这样的时间,根据用户所在时区,或者用户设置时区,格式化成对应风格的时间做展示,这样才能够做到真正意义上的国际化和本地化!
用户提交的表单也是同样的道理,前端负责将用户提交的数据从本地化转为 ISO 8601 标准再提交给后端!
让前端处理就好了,后端不是万能的
试试这篇:Laravel 中高效的用户时区处理
正常情况前端处理,如果不怕麻烦。自己写一个转换函数统一处理一下。后端处理也一样,先转时间戳在转UTC,PRC,知道本地存的是哪个时区的就行。百度一下或者问一下AI有很多处理办法,我没记错laravel有不用他的时间格式,你自己写一个setAttribute实现也行,原本框架原来本意就是前端处理这个问题
模型类全部时间字段加 getParamsAttribute 和 setParamsAttribute?
定义一个时区转换的trait,设置需要转换的字段,trait内重写model的getAttribute、setAttribute、attributesToArray,在这里面做转换,需要转换的模型use 这个trait
时间国际化的需求,数据库时间字段建议存时间戳,因为时间戳对于全球各个时区来说是唯一的,渲染的时候只需要用时区转换对应时区时间即可,也可以用开源的时间国际化的包进处理,封装公共方法即可。
多时区入库不是应该存时间戳吗
客户端可以选、提交UTC时间,显示本地时间