前言
在页脚放一行「本站最后更新:2026-09-29 10:00:00」是很多站点的常见需求,尤其是小工具站和内部后台。第一反应通常是写echo date('Y-m-d', filemtime(__FILE__));,本地跑起来也对,于是就这么上线了。问题会在后面慢慢浮现:页脚显示的时间比实际发布时间早了 8 小时;页面顶部明明改过,页脚时间却纹丝不动;换成另一台机器部署之后,时间突然变成了部署脚本执行的那一刻。
这几个现象分别对应三个独立的坑:时区、__FILE__的语义、以及文件 mtime 本身的含义。
先说清楚一件事:filemtime()返回的是文件在磁盘上的最后修改时间(mtime),它跟「这个页面的内容最后被谁改过」没有必然关系。git clone会把文件的 mtime 设成检出时间,打包发布(tar)会保留原时间,rsync -a保留源时间而rsync不带-a则用当前时间。所以这行字到底该显示什么,取决于你想表达的是「文件何时被改动」还是「站点何时发布」——前者用 mtime 合理,后者应该用一个由构建流程写入的版本常量。
本文把「取时间 → 转成正确时区 → 展示 → 当缓存版本号用」这条链路讲清楚,示例最低需要 PHP 8.1。
一、__FILE__、__DIR__与「当前脚本」的区别
这是最容易出错的一环。假设站点结构是这样:
site/ ├── index.php (入口) └── tpl/ └── footer.php (页脚模板,里面写这行时间)在footer.php里写filemtime(__FILE__),拿到的是footer.php自己的 mtime,不是index.php的。页脚模板几个月不动一次,而入口文件天天改,于是页脚的时间永远是几个月前。
正确的做法取决于你想表达什么:
| 写法 | 拿到的是谁的时间 | 适用场景 |
|---|---|---|
filemtime(__FILE__) | 当前这个文件自己 | 只想显示本文件何时被改 |
filemtime(__DIR__ . '/../index.php') | 硬编码的入口文件 | 结构固定的站点 |
filemtime($_SERVER['SCRIPT_FILENAME']) | 本次请求的入口脚本 | 多入口站点(Web 环境) |
| 构建期写入的常量或版本文件 | 发布时刻 | 最准确,推荐生产使用 |
$_SERVER['SCRIPT_FILENAME']在 CLI 下不存在,所以要有兜底。另外它在某些 SAPI(Server API)下可能与实际路径有出入,生产上更稳的做法是在构建或发布流程里生成一个版本文件,页脚只负责读它。
二、时区:不设就默认 UTC
date()使用的是date.timezone配置或date_default_timezone_set()设置的时区。PHP 8 在未设置时区时默认使用 UTC,不会报错——于是你的页面在 UTC+8 时区显示的时间比真实时间早 8 小时,而且不容易察觉(差 8 小时看起来也像个正常时间)。
正确做法是用不可变的DateTimeImmutable,把时区显式带上:
$dt = (new DateTimeImmutable('@' . $timestamp)) ->setTimezone(new DateTimeZone('Asia/Shanghai')); echo $dt->format('Y-m-d H:i:s');DateTimeImmutable有个额外好处:每次修改都返回新对象,不会像DateTime那样出现「某个变量在别处被改掉」的隐性 bug。
三、stat 缓存:改了文件读不到新值
PHP 会缓存stat()系列函数的结果,避免同一个请求里反复访问文件系统。这带来一个隐蔽问题:在长驻进程(常驻容器、queue worker、定时任务循环)里循环调用filemtime(),返回的可能是第一次读到的旧值。加了clearstatcache()才会重新读取:
clearstatcache(true, $path); // 第二个参数指定文件,只清这一个这两个参数的含义是:clearstatcache(bool $clear_realpath_cache = false, string $filename = ""),同时传文件名时只清除该文件的缓存,比清空全部要高效。
四、完整可运行示例
<?php declare(strict_types=1); /** * lastmod.php —— 读取脚本文件的最后修改时间并格式化 * 最低 PHP 8.1 * CLI 运行:php lastmod.php [文件路径] 默认取本文件 * Web 运行:放到站点里访问即可 */ final class FileTime { /** * @param string $timezone 目标时区,生产环境建议与站点展示时区一致 */ public static function format(int $timestamp, string $timezone = 'Asia/Shanghai'): string { return (new DateTimeImmutable('@' . $timestamp)) ->setTimezone(new DateTimeZone($timezone)) ->format('Y-m-d H:i:s'); } /** 人类可读的相对时间,例如「3 天前」 */ public static function human(int $timestamp, ?int $now = null): string { $now = $now ?? time(); $diff = $now - $timestamp; return match (true) { $diff < 0 => '(时间在未来,请检查文件时间)', $diff < 60 => '刚刚', $diff < 3600 => intdiv($diff, 60) . ' 分钟前', $diff < 86400 => intdiv($diff, 3600) . ' 小时前', $diff < 2592000 => intdiv($diff, 86400) . ' 天前', default => '很久以前', }; } /** 读取 mtime,失败时抛出异常而不是返回 0 */ public static function read(string $path): int { clearstatcache(true, $path); // 长驻进程里必须清,否则读到的是缓存值 $mtime = @filemtime($path); if ($mtime === false) { // 不能直接返回 0 或 false:传给 date() 会静默变成 1970-01-01 throw new RuntimeException('无法读取文件修改时间:' . $path); } return $mtime; } /** 用于 HTTP 头 Last-Modified,必须是 GMT 格式的固定写法 */ public static function httpDate(int $timestamp): string { return gmdate('D, d M Y H:i:s \G\M\T', $timestamp); } } // ---------------- 演示 ---------------- // Web 环境取本次请求的入口脚本;CLI 环境允许用参数指定,默认取本文件 $target = $argv[1] ?? ($_SERVER['SCRIPT_FILENAME'] ?? __FILE__); try { $mtime = FileTime::read($target); } catch (RuntimeException $e) { http_response_code(500); exit($e->getMessage() . PHP_EOL); } $relativePath = basename($target); printf("目标文件:%s%s", $relativePath, PHP_EOL); printf("最后修改:%s(%s)%s", FileTime::format($mtime), FileTime::human($mtime), PHP_EOL); printf("Unix 时间戳:%d%s", $mtime, PHP_EOL); printf("Last-Modified 头:%s%s", FileTime::httpDate($mtime), PHP_EOL); printf("静态资源版本号:?v=%d%s", $mtime, PHP_EOL);php lastmod.php目标文件:lastmod.php 最后修改:2026-09-29 10:00:00(刚刚) Unix 时间戳:1790647200 Last-Modified 头:Tue, 29 Sep 2026 02:00:00 GMT 静态资源版本号:?v=1790647200注意中间两行的差异:同一个时刻,本地时区显示10:00:00,而 HTTP 头必须是 GMT 的02:00:00 GMT。HTTP 协议要求Last-Modified用 GMT 格式,用date()会带上本地时区偏移,缓存协商就会失效。
页脚里实际使用的形态大致是这样:
function footer_last_modified(string $entryFile): string { try { return '本站最后更新:' . FileTime::format(FileTime::read($entryFile)); } catch (RuntimeException) { return ''; // 页脚信息不该因为读不到文件而让整页崩掉 } } echo footer_last_modified($_SERVER['SCRIPT_FILENAME'] ?? __FILE__);常见坑点
- ❌ 在
footer.php里用filemtime(__FILE__),以为拿到的是整站页面的时间。
✅ 改用$_SERVER['SCRIPT_FILENAME']取本次请求的入口脚本,或者读构建期生成的版本文件。
- ❌ 用
date()格式化filemtime()却没设置时区。
✅ 用DateTimeImmutable显式带上DateTimeZone,别依赖服务器的默认时区。
- ❌ 直接把
filemtime()的返回值传下去,不判断false。
✅ 先判断=== false再使用;false被强转成 0 会显示 1970-01-01,把「文件不存在」这个错误伪装成一个正常时间。
- ❌ 在长驻进程里循环读取 mtime,改了文件却读不到新值。
✅ 调用前clearstatcache(true, $path),只清目标文件的 stat 缓存。
- ❌ 把 mtime 当作「内容最后修改时间」展示给用户。
✅ 明确它的语义:git clone得到检出时间,rsync -a保留源时间,tar 解包保留打包时间;要展示发布时刻就用构建期写入的常量。
- ❌ 用 mtime 当静态资源的缓存版本号,却又用
touch()手动改时间。
✅ 版本号要与资源内容强相关,改时间就是「骗缓存」;要么用内容摘要,要么保证发布流程一定会更新 mtime。
- ❌ 用
date()生成Last-Modified响应头。
✅ HTTP 头必须用 GMT,写成gmdate('D, d M Y H:i:s \G\M\T', $mtime)。
- ❌ 每次请求都对几十个文件调用
filemtime()拼版本号。
✅ 每个文件一次stat系统调用,文件多起来开销可观;生产上把版本号在构建期算好写进一个常量文件,运行时只读一次。
总结
| 需求 | 推荐做法 | 关键点 |
|---|---|---|
| 显示文件何时被改 | filemtime(入口脚本路径) | 用$_SERVER['SCRIPT_FILENAME'],不要用__FILE__ |
| 显示站点何时发布 | 构建期写入的版本常量或版本文件 | 与文件系统时间解耦,部署方式无关 |
| 正确显示时间 | DateTimeImmutable+DateTimeZone | 不设时区就是 UTC |
| HTTP 缓存协商 | gmdate(...) | 必须是 GMT 格式 |
| 长驻进程中读取 | 先clearstatcache(true, $path) | 否则读到的可能是旧值 |
| 文件读取失败 | 抛异常或返回空字符串 | 不要让页脚因为一个时间信息崩掉整页 |
「显示脚本最后修改时间」看似一行代码,真正决定它是否可靠的其实是三个语义问题:取的是哪个文件的 mtime、这个 mtime 在你们的部署流程里意味着什么、以及时间要按哪个时区展示。把这三件事想清楚,页脚那行字才能一直是对的。