前言
伪静态(URL rewrite,地址重写)不生效,症状通常高度一致:本地开发环境因为跑的是php -S或者 Apache 带.htaccess,/article/123这样的地址访问得好好的;换到线上 nginx 之后,所有"漂亮路由"一律 404,但带index.php的老地址/index.php?r=article/view&id=123又完全正常。于是很多人第一反应是"PHP 版本不对"或者"框架坏了",去翻composer.json、去重装扩展,方向全跑偏了。
真相是:伪静态是 Web 服务器(nginx / Apache / IIS)的职责,PHP 7.4 在这件事上只是一个下游消费者。请求在到达 PHP 之前,就已经被服务器的 URL 匹配规则决定好了要交给谁。规则没命中,PHP-FPM 进程根本不知道有这次请求,自然也不会报错——你只会看到一个干净的 404,错误日志里什么都没有。
本文给出一条"十分钟定位到层"的排查路径,覆盖 nginx、Apache 两种主流部署,附一个能直接打印出路由相关$_SERVER变量的探针脚本,以及一份高频踩坑清单。
一、第一步:判断请求到底走到哪一层
不要先改配置,先取证。三条curl就能把问题定位到具体层:
# 1) 漂亮地址:如果这里 404 而下一步正常,说明问题在服务器重写规则 curl -i -s http://example.com/article/123 | head -n 20 # 2) 显式带入口文件的等价地址:这一步正常,说明 PHP 与框架路由本身没问题 curl -i -s "http://example.com/index.php?r=article/view&id=123" | head -n 20 # 3) 用 PATH_INFO 形态再试一次,用来区分"没重写"和"重写了但没传 PATH_INFO" curl -i -s http://example.com/index.php/article/123 | head -n 20三次结果组合起来,结论非常明确:
| 第 1 次 | 第 2 次 | 第 3 次 | 结论 |
|---|---|---|---|
| 404 | 200 | 200 | 重写规则没生效(nginxtry_files/ ApacheRewriteRule没命中) |
| 404 | 200 | 404 | 重写生效了,但PATH_INFO没传给 PHP-FPM |
| 404 | 404 | 404 | 与伪静态无关,先查站点根目录、root指令、PHP-FPM 是否在跑 |
| 200 | 200 | 200 | 服务器层没问题,去看框架路由匹配顺序(路由前缀、大小写敏感等) |
关键技巧是看 404 的响应头而不是响应体。nginx 自己产生的 404 通常带Server: nginx且没有X-Powered-By;PHP 产生的 404 一般会带上框架的特征头。这一眼就能区分"请求没到 PHP"和"到了 PHP 但路由没匹配"。
# 用 -v 看清请求头与响应头,确认请求确实被改写成了 /index.php curl -v -s http://example.com/article/123 2>&1 | grep -E '^(>|<)' # 看上游到底收到了什么 URI(关键字段是 access log 里的 $request 与 $upstream_...) tail -n 50 /var/log/nginx/access.log二、nginx 侧:最常见的三类失效
1.try_files的最后一段把查询串吃掉了
这是最高频的一条。很多教程里抄来的写法是:
# ❌ 少了一个字符:末尾是 /index.php 而不是 /index.php? location / { try_files $uri $uri/ /index.php; }try_files的最后一个参数是内部重定向的目标 URI。写成/index.php时,nginx 会把原始请求的查询串丢弃,于是?r=article/view&id=123到了 PHP 那边就变成了空。用户看到的现象就是"页面能打开,但永远是首页/空数据"。
# ✅ 推荐写法一:显式保留查询串($is_args 在有查询串时是 "?",否则是空串) location / { try_files $uri $uri/ /index.php$is_args$args; } # ✅ 推荐写法二:Laravel 文档中常见的等价写法 location / { try_files $uri $uri/ /index.php?$query_string; }$query_string是$args的别名,?直接写死也没关系——查询串为空时目标就是/index.php?,nginx 会忽略这个空查询串。
2.location ~ \.php$匹配不到带 PATH_INFO 的地址
$表示字符串结尾,所以/index.php/article/123根本不会命中下面这个块,nginx 会直接 404:
# ❌ 只匹配以 .php 结尾的 URI location ~ \.php$ { include fastcgi_params; }# ✅ 同时匹配 .php 之后还有路径的情况,并把 PATH_INFO 传下去 location ~ [^/]\.php(/|$) { fastcgi_split_path_info ^(.+?\.php)(/.*)$; include fastcgi_params; # 提供 SCRIPT_NAME、QUERY_STRING 等 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; fastcgi_pass unix:/run/php/php7.4-fpm.sock; }fastcgi_split_path_info用两个捕获组把 URI 拆成"脚本路径"和"路径信息",第二个捕获组通过$fastcgi_path_info传给PATH_INFO。少了这一行,PHP 里$_SERVER['PATH_INFO']就是空的——这正是第 3 次curl失败、第 2 次成功的那个组合。
3. 规则被更靠前的location抢先匹配
nginx 的匹配顺序是:先找最长前缀匹配的location,记住它;再按配置文件中的出现顺序逐个尝试正则location,一旦命中就立即采用(^~修饰符可以阻止正则继续匹配)。很多人把location ~* \.(js|css|png)$写在前面,又把location /写在后面,结果静态资源的规则把动态地址也截走了。
# ❌ 正则 location 放在最前面,会盖掉后面的 location / location ~* \.(js|css|png|jpg)$ { expires 30d; } location / { try_files $uri $uri/ /index.php$is_args$args; }# ✅ 前缀 location 在前;或用 ^~ 明确"命中前缀就不要再看正则" location / { try_files $uri $uri/ /index.php$is_args$args; } location ^~ /static/ { expires 30d; } location ~* \.(js|css|png|jpg)$ { expires 30d; }改完配置别用nginx -s reload前先自检,并确认线上加载的确实是这份文件:
nginx -t # 语法检查 nginx -T | grep -n -A6 'location /' # 打印合并后的完整配置,确认规则真的在其中 systemctl reload nginxnginx -T会把 include 进来的所有片段合并输出,是排查"我明明改了配置文件却不生效"(改错文件、被别的 include 覆盖)的最快手段。
三、Apache 侧:.htaccess写了却没反应
Apache 上"伪静态不生效"几乎只有一个原因:.htaccess根本没被读取。再往下分三种:
# 1) mod_rewrite 没启用 —— RewriteRule 会被当成未知指令直接 500 或忽略 apachectl -M | grep rewrite # Debian/Ubuntu 系启用 a2enmod rewrite && systemctl restart apache2 # 2) 站点配置里 AllowOverride 是 None —— .htaccess 被完全忽略,且不报错 # 检查站点 conf: grep -n -A5 '<Directory' /etc/apache2/sites-enabled/000-default.conf# ❌ AllowOverride None:.htaccess 内容被彻底无视,也不会有任何提示 <Directory /var/www/html> AllowOverride None </Directory># ✅ 至少给到 FileInfo,重写规则才生效 <Directory /var/www/html> AllowOverride FileInfo Options -MultiViews </Directory>第 3 种更隐蔽:ModNegotiation 的 MultiViews。开了 MultiViews 之后,Apache 会把/article/123这种"看起来像目录"的请求先做一轮内容协商,尝试匹配article.php之类的文件。表现是"路由偶尔对、偶尔错",非常难查。用Options -MultiViews关掉它。
规则本身也要写对,特别注意[L]与[QSA]:
# ❌ 缺少条件判断:连真实存在的静态文件也会被丢给 index.php RewriteRule ^(.*)$ index.php [L] # ❌ 忘记 QSA:原始查询串被 rewrite 的目标覆盖掉 RewriteRule ^ index.php?r=$1 [L]# ✅ 标准写法:先排除真实文件与真实目录,再重写,并用 QSA 保留原查询串 <IfModule mod_rewrite.c> RewriteEngine On RewriteBase / RewriteCond %{REQUEST_FILENAME} !-f RewriteCond %{REQUEST_FILENAME} !-d RewriteRule ^ index.php [L,QSA] </IfModule>RewriteBase /在站点部署在子目录(比如/shop/)时是必须的,少了它会导致重写目标指向错误的位置。
四、实战:一个路由探针脚本
不管用哪种服务器,先放一个探针文件上去,把 PHP 侧真正收到的东西打出来,比猜快得多。
<?php // router-probe.php —— PHP 7.4 兼容 // 放到站点根目录,访问 /router-probe.php/article/123?a=1 观察输出 // 排查完请立刻删除该文件 $keys = [ 'REQUEST_URI', // 原始请求行里的 URI,含查询串,永远最可信 'SCRIPT_NAME', // 当前执行的脚本路径,重写后通常是 /index.php 'PHP_SELF', // 含 PATH_INFO 的脚本路径 'PATH_INFO', // 重写规则传入的额外路径,nginx 需要 fastcgi_param 才会出现 'QUERY_STRING', // 查询串,被 try_files 吃掉时这里为空 'ORIG_PATH_INFO', 'DOCUMENT_ROOT', 'REQUEST_METHOD', ]; header('Content-Type: text/plain; charset=utf-8'); foreach ($keys as $k) { printf("%-16s : %s\n", $k, isset($_SERVER[$k]) ? $_SERVER[$k] : '(未设置)'); } // 把 URL 解析成路由片段的常见做法:优先用 REQUEST_URI,而不是 PATH_INFO $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH); $path = trim(rawurldecode($path), '/'); $segments = $path === '' ? [] : explode('/', $path); echo str_repeat('-', 40), "\n"; echo 'rewritten path : /', implode('/', $segments), "\n"; echo 'controller : ', $segments[0] ?? '(home)', "\n"; echo 'action : ', $segments[1] ?? 'index', "\n"; echo 'params : ', json_encode(array_slice($segments, 2), JSON_UNESCAPED_UNICODE), "\n"; // 顺带打印服务器层信息,帮助确认是哪一层在应答 printf( "sapi=%s php=%s server_software=%s\n", PHP_SAPI, PHP_VERSION, $_SERVER['SERVER_SOFTWARE'] ?? '(未知)' );访问/router-probe.php/article/123?a=1与/article/123?a=1(后者走伪静态)两条地址对比输出,可以立刻分辨:
- 两者输出一致 → 重写完全正常,问题在前端路由/框架。
- 后者
QUERY_STRING为空而前者不为空 → 命中了try_files ... /index.php缺少?的写法。 - 后者
PATH_INFO为空 →fastcgi_split_path_info/fastcgi_param PATH_INFO没配。 - 后者 404 而前者 200 → 重写规则整体没命中,回到第二、三节。
常见坑点
1. 改了.htaccess以为立即生效,其实AllowOverride None让它从未被读取
# ❌ 只重启服务,从不去确认配置有没有被加载 systemctl restart apache2# ✅ 先确认 AllowOverride,再看错误日志里是否有 .htaccess 相关告警 apachectl -t -D DUMP_RUN_CFG 2>&1 | head tail -n 50 /var/log/apache2/error.log2. 用 nginxtry_files时把查询串丢了,页面"能开但没数据"
# ❌ try_files $uri $uri/ /index.php;# ✅ try_files $uri $uri/ /index.php$is_args$args;3. 用location ~ \.php$却要在 URL 里带额外路径
# ❌ /index.php/article/123 命中不了,nginx 直接 404,PHP 日志里什么都没有 location ~ \.php$ { fastcgi_pass unix:/run/php/php7.4-fpm.sock; }# ✅ 允许 .php 后面还有路径,并显式传 PATH_INFO location ~ [^/]\.php(/|$) { fastcgi_split_path_info ^(.+?\.php)(/.*)$; fastcgi_param PATH_INFO $fastcgi_path_info; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_pass unix:/run/php/php7.4-fpm.sock; }4. 反代 / CDN 把重写规则"抹平"了,本地怎么调都没用
# ❌ 只看源站配置,忽略了前面还有一层反代 systemctl status nginx# ✅ 直连源站 IP 与 Host 头测试,剥离反代因素 curl -i -s -H 'Host: example.com' http://10.0.0.12/article/123 | head -n 20如果直连源站正常、走域名异常,问题就在反代层(它可能没透传Host,或把 URI 归一化成了/)。
5. 用户浏览器缓存了旧的 301,改了规则"看起来还是不生效"
# ❌ 改完规则用浏览器刷新,看到的还是上次的 301 跳转# ✅ 用 curl 绕开一切缓存;配置里也别对动态路由发永久重定向 curl -i -s http://example.com/article/123 | head -n 5 # ✅ 确实需要跳转时用 302,避免 301 被浏览器长期缓存 # nginx: return 302 /new-path;6.index index.php index.html;让静态首页抢走了/
# ❌ 请求 / 时先命中 index.html,动态首页永远进不去 index index.php index.html;# ✅ 把动态入口排在最前,或直接删掉不需要的静态首页 index index.php;7. 把重写规则写在错误的 server 块里(多域名共存时)
# ❌ 站点有两个 server(主站 + 移动站点),规则只加在了其中一个 server { listen 80; server_name m.example.com; location / { try_files $uri /index.php$is_args$args; } }# ✅ 用 nginx -T 确认规则出现在哪个 server 块中,必要时抽成公共文件 include nginx -T | grep -n -B5 'try_files'8. 探针文件留在生产环境
// ❌ 排完问题就忘了删,探针把服务器路径、SAPI、版本信息暴露给任何人 // (就像本文第四节的 router-probe.php)# ✅ 排查完成后立即删除,或至少在代码里加一层 IP 白名单判断后退出 rm -f /www/html/router-probe.php总结
| 现象 | 首选排查点 | 典型原因 |
|---|---|---|
漂亮地址 404,带index.php正常 | 服务器重写规则 | try_files未命中 /.htaccess未被读取 |
| 页面能开但无数据 | 查询串是否保留 | try_files /index.php少了? |
/index.php/xxx也 404 | nginx 的location正则 | \.php$结尾匹配不到带路径的 URI |
| 改了配置没反应 | 加载的到底是哪个文件 | 改错文件、被 include 覆盖、未 reload |
| 本地好线上坏 | 部署形态差异 | Apache/.htaccessvs nginx,或前面还有反代 |
结论:排查伪静态永远从"请求到了哪一层"开始,而不是从 PHP 代码开始。三条curl确定层次,nginx -T确认配置真的被加载,探针脚本确认 PHP 收到的REQUEST_URI/PATH_INFO/QUERY_STRING是否如预期。这三步做完,剩下的只是改哪一行配置的问题;反过来,如果不做分层直接去翻框架路由代码,很可能在完全正确的 PHP 代码里找一整天。