PHP内置服务器URL重写:用router script实现本地单入口路由
2026/9/15 3:34:24 网站建设 项目流程

本地开发用 PHP 内置服务器(php -S)起服务,最常被吐槽的一点就是:它既没有 Apache 的 .htaccess,也没有 Nginx 的 location 和 rewrite 模块,一访问 /user/1 这种不带 .php 后缀的地址,直接甩你一个 404。很多人到这一步就放弃了,转头装一套 Nginx,或者把线上的重写规则复制过来靠猜,结果本地能跑、线上打脸,或者反过来。其实在 PHP 内置服务器上实现 URL 重写,核心只有一个东西——router script,一个普普通通的 PHP 文件,写好了不超过三十行代码,就能让 /user/1、/api/post/42 这类干净路径在本地跑起来,而且这套路由逻辑和线上 Nginx 的 try_files 行为可以保持高度一致。这篇内容会从内置服务器的请求处理机制讲起,把 router script 的判定规则拆开,给出可以直接抄走的完整代码,再把我自己踩过的 404、死循环、单线程卡顿这些坑逐个交代清楚。不管你是在写原生 PHP 小项目、调试 ThinkPHP/Laravel 这类框架,还是给前端同学搭一个本地接口服务,都能直接拿去用。

1. 为什么内置服务器还要谈 URL 重写

先说清楚一个前提:URL 重写这个需求不是凭空造出来的,它来自"单入口"这个架构习惯。几乎所有现代 PHP 项目都是 index.php 一个入口,所有请求先进这个文件,再由它根据路径分发到不同的控制器。这么做的理由很实在——权限控制、日志记录、初始化逻辑只需要写一遍,不用在每个页面文件顶部复制粘贴。但单入口天然要求 URL 重写:用户看到的是 /user/1,服务器内部要把它交给 index.php 处理。

Apache 时代用 .htaccess 里的 RewriteRule,Nginx 时代用 location + try_files,这些规则都有一个共同特征:写的是正则,是配置,是"声明式"的。而 PHP 内置服务器根本不读这些配置文件,它走的是另一条路——你启动时多带一个参数,把某个 PHP 文件指定为"路由脚本",之后每一个请求都先交给这个脚本,由脚本自己用 PHP 代码决定:是直接处理,还是交回去当静态文件输出。换句话说,URL 重写在内置服务器上不是配置问题,是代码问题。

1.1 内置服务器的真实定位:开发调试工具,不是生产服务器

我见过不少人问"php -S 能扛多少并发",这个问题本身就问偏了。内置服务器从设计之初就定位在开发调试场景:默认单进程单线程、没有进程管理器、没有访问日志轮转、不支持虚拟主机、不做 SSL。它的价值在于"零配置启动"——你 clone 一个项目下来,不用装 Nginx、不用配 vhost、不用改 hosts,敲一行命令就能跑,改完代码刷新即生效。

理解了这一定位,很多决策就顺了。比如本地路由规则要不要跟线上一模一样?我的做法是"行为等价即可,实现不必相同"。线上可能是try_files $uri $uri/ /index.php?$query_string;,本地就在 router script 里模拟这个顺序:先看真实文件、再看目录、最后兜底进 index.php。这样两边对同一批 URL 的响应结果一致,切换环境时就不会出现"本地 200、线上 404"这种玄学现象。

注意:内置服务器只在 PHP 5.4 及以上版本才有,这个功能已经存在十几年了,如果你的环境里没有 php -S,先确认是不是装了个残缺的 PHP 包。

1.2 单入口架构为什么离不开重写

假设你的项目结构是 public/index.php 作为唯一入口,控制器里定义了三类路由:首页/、用户详情/user/{id}、文章接口/api/post/{id}。在没有重写的情况下,访问首页必须写/index.php,访问用户页必须写/index.php?c=user&id=1。这种 URL 除了难看,还有几个实际麻烦:一是前端同学联调时拼错参数名,二是缓存和分享链接时不同写法指向同一资源,三是路由参数和查询参数混在一起,id到底是路径参数还是筛选条件说不清楚。

重写之后,/user/1/index.php?c=user&id=1在语义上就是同一个资源,只是前者对外,后者是内部实现细节。这个"内外分离"的好处在使用 RESTful 风格 API 时尤其明显:GET /api/post/42一看就知道是取 ID 为 42 的文章,而?a=detail&id=42需要你翻开代码才知道 a=detail 是什么意思。

1.3 从正则配置转到代码路由,思维上要换个档

写惯了 Nginx 的人第一次写 router script,最容易犯的错误是"把正则堆在配置文件里"的思路带过来。Nginx 里你会写一堆 rewrite 规则按顺序匹配,谁先命中谁生效;而在路由脚本里,我更推荐"分层判断"而不是"线性正则":

判断层判断依据处理方式
第一层:静态资源扩展名在白名单内,且文件真实存在return false交给服务器原样输出
第二层:真实 PHP 文件路径指向 docroot 下已存在的 .php视安全策略决定是否放行
第三层:应用路由其他所有情况引入 index.php,由应用分发

这个分层顺序的好处是可预测:静态资源永远不会被应用逻辑拦截,路由分发也不会因为某个临时文件的存在而意外短路。后面第三章的完整代码就是按这个顺序写的。

2. router script 的判定机制:return false 不是随便写的

内置服务器的路由脚本有一个非常关键、也非常容易被忽略的约定:判断路由脚本"是否已经处理完请求"的标准,是它的返回值是否严格等于 false。这句话值得逐字读三遍,因为大量莫名其妙的 bug 都出在这里。

具体来说,当你启动php -S 127.0.0.1:8000 router.php时,每一个 HTTP 请求都会先被解释器丢给 router.php 执行。如果这个脚本执行完毕,返回值是布尔 false,内置服务器就认为"你自己不处理,那我来"——它会回到 docroot 目录下,按照请求路径去找真实文件,找到静态文件就按对应 MIME 类型输出,找到 .php 文件就交给解释器执行,找不到就返回 404。反过来,如果脚本正常执行结束,没有return false,那内置服务器就认定"这个响应你已经自己输出完了",它不会再做任何兜底动作,哪怕你一个字都没输出,浏览器也会收到一个空响应。

2.1 一次请求在内置服务器内部的完整走向

把这个过程拆细一点,方便你在排查问题时对照:

  1. 解析请求行,取出请求方法、原始 URI(含查询串);
  2. 从 URI 中剥离查询串,得到路径部分,并对路径做一次百分号解码;
  3. 检查启动时是否指定了路由脚本,如果有,先执行它,并把它返回的值记下来;
  4. 若返回 false,则按 docroot 查找路径对应的资源:是目录就先找 index.php 再找 index.html;是静态文件就按 MIME 输出;是 .php 就执行;都没有就 404;
  5. 若不是 false,直接结束,不再做任何文件查找。

这里有个细节值得注意:步骤 3 里,路由脚本的当前工作目录是启动命令执行的目录,而不是脚本所在目录。我在一次多人协作的项目里就吃过这个亏——同事在项目根目录执行php -S 127.0.0.1:8000 tools/router.php,我在 tools 目录里执行同样的命令,结果一个能跑一个 404。原因是我在脚本里用相对路径'public/index.php'去 require,工作目录一变就找不到了。解决办法很简单,脚本里统一用__DIR__拼绝对路径。

2.2 REQUEST_URI、PATH_INFO、SCRIPT_NAME 的真实取值

这几个超全局变量在 Apache/Nginx 下和在内置服务器下取值差别不小,我列个表对照一下,省得你为了一个路径参数 debug 半小时。

变量直接访问/user/1(无路由脚本)经 router script 接管后
REQUEST_URI/user/1/user/1,与前者一致
SCRIPT_NAME/user/1/router.php,会暴露路由脚本文件名
PHP_SELF/user/1/router.php
PATH_INFO通常为空通常为空,内置服务器不会自动填充
DOCUMENT_ROOT-t指定的目录同左
$_GET从查询串解析,正常同左,与路径参数无关

表格里最扎眼的是SCRIPT_NAME。很多模板引擎和框架在生成链接时会用SCRIPT_NAME作为 base path,结果本地调试时页面上所有链接变成了/router.php/user/1,点一下就 404。我一般会在路由脚本的最开头手动覆写它:

$_SERVER['SCRIPT_NAME'] = '/index.php'; $_SERVER['PHP_SELF'] = '/index.php';

这样应用层拿到的基础路径就是干净的/index.php,生成的链接也不会带出路由脚本。这个改动很小,但能省掉一堆"为什么页面上链接多了一截"的困惑。

另一个要注意的是PATH_INFO。Apache 在开启 AcceptPathInfo 时会把/index.php/user/1里的/user/1塞进 PATH_INFO,很多人写路由习惯直接读它。内置服务器不做这件事,PATH_INFO 在绝大多数情况下是空的。所以路由参数的解析必须自己动手:用parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)取路径,再用explode或正则拆段。这是内置服务器和传统环境最实质的差异之一,也是从 Apache 迁过来最容易翻车的地方。

2.3 静态文件的"短路"边界在哪里

return false看起来只是"交回去",但它实际触发的是内置服务器自己的文件查找逻辑,这个逻辑有边界,不清楚边界的后果是安全问题和诡异 404。

第一,它只认 docroot 之下的文件。如果你用-t public指定了文档根,那么return false时服务器只会去 public 目录里找,public 之外的代码目录天然不可访问。这是内置服务器比很多手写路由都安全的一点,别浪费它。

第二,路径里出现..会被内置服务器自己处理掉,它不会让你跳出 docroot。但你不能指望这个行为去兜底你自己拼出来的路径——比如你在代码里用用户输入的字符串去拼file_exists($root . $input),那是在路由脚本内部执行的文件系统调用,内置服务器管不到。任何把外部输入拼进文件路径的写法,都必须自己做规范化校验。

第三,以点开头的隐藏文件,比如.env.git/config,虽然内置服务器对这类路径的处理有些版本差异,但我的建议是彻底别赌运气。在路由脚本里做白名单而不是黑名单:只放行你认识的扩展名,其余一律走应用逻辑或直接 404。白名单思路虽然啰嗦一点,但不会因为将来多了一个配置文件就突然变成公开可下载资源。

3. 手写一个能长期用的 router.php

前面讲了原理,现在进入能直接落地的部分。我会从一个最小版本开始,逐步补上静态资源处理、路径穿越防护、404 兜底,最后给出一份我实际在用的完整文件。你可以按顺序读,也可以直接跳到 3.4 抄走。

3.1 十行代码跑通单入口

先看最小可用版本,理解结构比记住代码重要:

<?php // router.php $root = __DIR__ . '/public'; $uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); // 只放行真实存在的静态文件,其余全部交给单入口 if ($uri !== '/' && is_file($root . $uri)) { return false; } require $root . '/index.php';

配套启动命令:

php -S 127.0.0.1:8000 -t public router.php

这个版本能解决 80% 的场景:访问/会落到 index.php,访问/css/app.css会因为文件存在而返回 false 被原样输出,访问/user/1会被 index.php 接管。但它有三个明显问题:一是is_file判断只对文件生效,如果路径指向一个目录就会走应用逻辑,而应用逻辑可能把它当 404,也可以接受;二是没做路径规范化,/../config.php这种输入会原样拼进$root,虽然有内置服务器兜底,但在脚本内部拼路径仍然是不好的习惯;三是没区分扩展名,docroot 下任何存在的文件都会被放行,包括你不希望暴露的备份文件。

3.2 静态资源白名单与 MIME 兜底

第二个版本加上扩展名白名单。这个改动看起来保守,实际上是让路由行为变得可预期:

$staticExt = [ 'css', 'js', 'mjs', 'map', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'avif', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'txt', 'json', 'xml', 'pdf', 'mp3', 'mp4', 'webm', 'ogg', ]; $ext = strtolower(pathinfo($uri, PATHINFO_EXTENSION)); if ($ext !== '' && in_array($ext, $staticExt, true)) { $file = realpath($root . $uri); if ($file !== false && is_file($file) && strncmp($file, realpath($root), strlen(realpath($root))) === 0) { return false; } http_response_code(404); exit; }

这段代码里有三个点值得说。

第一,先判断扩展名,再判断文件是否存在。顺序反过来的话,黑客可以用/config.php.bak这种路径去探测文件存在性——虽然暴露的信息很有限,但没必要留这个口子。

第二,用 realpath 做二次校验realpath会把路径里的..、软链接全部展开成绝对路径,然后我用前缀比较确认它确实落在 docroot 之内。这里注意用strncmp而不是str_starts_with,因为str_starts_with是 PHP 8 才有的函数,很多项目还在 7.4 上跑。如果你确定是 PHP 8,直接写str_starts_with($file, $rootReal)更直观。

第三,白名单命中的资源如果不存在,直接返回 404,不要回落到应用路由。原因是这样更符合直觉:/css/app.css不存在就是 404,不应该被前端路由接管然后返回一个 HTML 首页,那会让浏览器报 MIME 类型错误,排查起来更绕。

3.3 查询串、路径参数与 URL 解码

查询串这部分反而是内置服务器做得最省心的地方。parse_url只取 path 部分,$_GET由解释器自动填充,所以/user/1?tab=posts&page=2这种混合写法不需要额外处理:路径部分你自己拆,查询参数照常从$_GET读,互不干扰。

真正要小心的是解码时机REQUEST_URI是原始编码的,中文和空格都还是%E4%B8%AD%20这种形式。如果你拿它跟路由正则匹配,那就得用编码后的形式写正则,非常难维护。所以第一步先取 path,第二步做一次rawurldecode,之后所有匹配和文件判断都在解码后的字符串上做。下面这个顺序不能颠倒:

$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); // 原始编码 $path = rawurldecode($path); // 解码后 $path = preg_replace('#/+#', '/', $path); // 合并连续斜杠 $path = rtrim($path, '/') ?: '/'; // 统一末尾斜杠

最后这行rtrim是个小技巧。同一个资源/user/1/user/1/在搜索引擎眼里是两个 URL,在缓存里也是两条记录。开发阶段统一去掉末尾斜杠,可以减少很多"这个页面为什么缓存没生效"的问题。如果你更喜欢保留末尾斜杠,把rtrim换成确保末尾有斜杠即可,关键是全站策略统一,别一半有一半没有。

关于rawurldecodeurldecode的区别,顺带提一句:urldecode会把+解码成空格,这在处理查询串时是对的,但用在路径上就错了——路径里的+就是加号本身。用rawurldecode处理路径,用parse_str或直接读$_GET处理查询串,各司其职。

3.4 一份可以直接抄走的完整 router.php

把前面的点合起来,这是我目前在用的版本,稍微长一点但每个分支都有明确意图:

<?php // router.php —— 放在项目根目录,与 public/ 同级 $root = __DIR__ . '/public'; $rootReal = realpath($root); $staticExt = [ 'css', 'js', 'mjs', 'map', 'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'avif', 'woff', 'woff2', 'ttf', 'eot', 'otf', 'txt', 'json', 'xml', 'pdf', 'mp3', 'mp4', 'webm', ]; $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $path = rawurldecode($path); $path = preg_replace('#/+#', '/', $path); $path = rtrim($path, '/') ?: '/'; // 1. 静态资源:白名单 + 存在性 + 目录内校验 $ext = strtolower(pathinfo($path, PATHINFO_EXTENSION)); if ($ext !== '' && in_array($ext, $staticExt, true)) { $file = realpath($root . $path); if ($file !== false && is_file($file) && strncmp($file, $rootReal, strlen($rootReal)) === 0) { return false; } http_response_code(404); header('Content-Type: text/plain; charset=utf-8'); echo '静态资源不存在: ' . $path; exit; } // 2. 统一 base path,避免模板生成 /router.php/xxx 的链接 $_SERVER['SCRIPT_NAME'] = '/index.php'; $_SERVER['PHP_SELF'] = '/index.php'; // 3. 其余请求交给单入口 require $root . '/index.php';

配套的 public/index.php 里,路由分发可以写得非常轻:

<?php // public/index.php $path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH); $path = rtrim(rawurldecode($path), '/') ?: '/'; $method = $_SERVER['REQUEST_METHOD']; $routes = [ 'GET' => [ '#^/$#' => 'home', '#^/user/(\d+)$#' => 'user_show', '#^/api/post/(\d+)$#' => 'api_post_show', ], 'POST' => [ '#^/api/post$#' => 'api_post_create', ], ]; $hit = false; foreach ($routes[$method] ?? [] as $pattern => $name) { if (preg_match($pattern, $path, $m)) { $params = array_map('rawurldecode', array_slice($m, 1)); header('X-Route: ' . $name); // 调试利器,见 5.4 dispatch($name, $params); $hit = true; break; } } if (!$hit) { http_response_code(404); readfile(__DIR__ . '/../views/404.html'); }

这段路由代码里,header('X-Route: ...')是我强烈建议加的一行。联调的时候打开浏览器开发者工具的 Network 面板,一眼就能看到当前请求命中了哪条规则,不用再去翻日志或者在每个控制器里加断点。等上线前把这个头去掉或者只在APP_ENV=dev时输出即可。

4. 踩坑记录:那些让人怀疑人生的 404 与死循环

原理和代码讲完了,接下来是本文最值钱的部分。下面这几个问题我都真实遇到过,有的是自己写出来的,有的是帮同事排查的,共同特点是:现象很吓人,原因很简单,找出原因的过程很有普适性。

4.1 重定向死循环是怎么长出来的

现象:浏览器提示"重定向次数过多",Network 面板里一串 302 首尾相连,地址栏里的路径越变越长,比如/index.php/index.php/index.php/...

成因通常是这样一行代码:

if ($path !== '/') { header('Location: /index.php' . $path); exit; }

写这段代码的人想的是"把路径交给入口处理",但他忘了/index.php本身也要经过路由脚本。于是一个/user/1被转成/index.php/user/1,这个新地址又命中同一条件,再转一次,浏览器跟到第 20 跳就罢工了。

排查这一类问题有个很高效的姿势:用curl -I看响应头,用curl -sIL -o /dev/null -w '%{num_redirects} %{url_effective}\n'看最终跳转次数和目标地址。比在浏览器里点来点去快得多。

修法有两个方向。一是跳转前先判断目标是不是真实文件,是就放行,不是才继续;二是干脆不做这种跳转,单入口架构下路径本来就应该由 index.php 内部解析,不需要用 302 绕一圈。我倾向后者,少一次跳转,也少一个出问题的地方。如果确实需要做规范化跳转(比如统一去掉末尾斜杠),一定确保跳转目标本身不会再命中跳转条件——最简单的方式是跳转前检查当前路径已经是目标形式就直接往下走。

4.2 目录被当成文件、文件被当成入口

第二个高频坑是"判断顺序不对"导致的诡异现象。我遇到过这样一次:项目里有个public/uploads/目录存用户上传的图片,路由脚本里写的是if (file_exists($root . $path)) return false;。结果访问/uploads(不带末尾斜杠)时,file_exists对目录也返回 true,于是return false,内置服务器去找uploads这个目录,发现有目录但没有 index 文件,返回 404。看起来只是个小问题,但前端上传完图片后拼接的预览地址恰好就是/uploads,用户看到一片空白,反馈是"上传成功了但图不显示"。

排查这类问题,关键是养成区分 is_file 和 file_exists的习惯。判断"这是不是一个可以直接返回的静态资源",用is_file;判断"这个路径存在不存在",才用file_exists。前者排除了目录,语义更准确。

还有一个反向的坑:docroot 下存在一个.php文件,比如public/info.php,路由脚本把请求原样交回去,内置服务器发现是 PHP 文件就执行了。而info.php里是一个调试用的phpinfo(),没有任何访问控制。本地无所谓,但如果这个 docroot 直接同步到了测试环境,那就是个不大不小的暴露面。我的做法是:docroot 下只允许放真正需要对外暴露的入口文件,调试脚本一律放到 docroot 之外,用命令行跑。如果实在要放,路由脚本里加一条显式拦截:

if (preg_match('#^/(info|phpinfo|test)\.php$#', $path)) { http_response_code(404); exit; }

4.3 单线程排队:为什么页面"很慢"但 CPU 很闲

这个坑最容易被误判成性能问题。现象是:本地打开首页,慢;打开开发者工具的 Network 面板,发现所有请求的耗时都差不多,而且是一批一批出结果的——先是第一个请求等了 800ms,然后接下来五个请求同时开始。CPU 占用几乎为零,数据库也没有慢查询。

原因就在内置服务器的默认行为上:它默认只用一个进程处理请求,前一个请求没结束,后面的请求就得排队。开发阶段这个问题被放大,是因为我们经常在路由里插桩、打日志、断点调试,一个请求卡住十几秒,页面上的其他资源请求全部干等。前端 dev server 的模块加载动辄几十个请求,串行处理下来,页面白屏时间能拉到肉眼可见。

解决办法是给内置服务器开多进程。PHP 7.4 起支持通过环境变量指定工作进程数:

PHP_CLI_SERVER_WORKERS=4 php -S 127.0.0.1:8000 -t public router.php

这个环境变量只在类 Unix 系统上生效,Windows 上不支持。另外要注意,多进程模式下每个请求可能落在不同进程里,所以不要依赖进程内的全局状态(比如静态变量缓存的配置、进程内计数器),这类状态在多进程下是不共享的。跨请求要共享的数据,老实用文件缓存、Redis 或者别的外部存储。

如果你在 Windows 上做开发,又确实被串行请求拖累了,我的建议是:要么把前端资源打包成少数几个文件,减少并发请求数;要么干脆换用一套本地的 Nginx + PHP 把请求接起来,把内置服务器只留给纯粹的接口调试。这不是内置服务器不好用,而是它的设计目标里本来就没有"扛并发"这一项。

4.4 中文路径、空格与百分号

路径里带中文,在内置服务器上要多做一步。因为REQUEST_URI是编码后的,如果你直接拿它去拼文件路径,得到的是/uploads/%E6%B5%8B%E8%AF%95.pdf,文件系统当然找不到。前面 3.3 里讲过要rawurldecode,但解码之后还有第二层坑:解码后的路径里可能出现空格、#?这些字符

比如文件名是产品 说明.pdf,解码后路径里带空格。用这个路径做realpath一般没问题,但如果你的应用层要把这个路径拼成 URL 再输出(比如生成下载链接),就必须重新编码,用rawurlencode逐段编码,而不是对整个路径整体编码——整体编码会把/也编码成%2F,链接就废了:

$segments = explode('/', $path); $encoded = implode('/', array_map('rawurlencode', $segments));

至于#,它本来是 URL 里的片段标识符,正常情况下浏览器不会把它发给服务器。但如果文件名里真的含#,编码后再传输就没问题,解码后拿到的也是原样字符。我吃过一次亏是文件名里带?,前端拼接时没编码,结果?之后的内容被当成查询串发走了,服务器收到的路径少了半截,查了半天才想到是文件名的问题。凡是用户可控的文件名,进入路径之前一定做白名单字符过滤或者统一重命名,这是比编码问题更根本的解法。

5. 把本地环境推近线上:多入口、联调与启动封装

路由脚本写到能跑,只是及格线。真正让本地开发舒服的,是后面这些工程化上的小设计。这一章讲的是怎么让内置服务器在你手里"更像一套环境",而不是一个只能跑 Hello World 的玩具。

5.1 多入口项目的降级处理

有些项目天生不是单入口,比如后台管理是一套独立入口/admin/index.php,前台是/index.php,还有一个/api/index.php。这种结构也能用内置服务器跑,只要在路由脚本里把"真实存在的 PHP 入口"放到静态资源判断之后、应用分发之前。

顺序上有个细节:不要无脑放行所有 .php 文件,而是维护一个入口文件的显式清单

$entryPoints = ['/index.php', '/admin/index.php', '/api/index.php']; if (in_array($path, $entryPoints, true)) { $file = realpath($root . $path); if ($file !== false && is_file($file)) { return false; // 让内置服务器执行该 PHP 文件 } }

写成清单的好处是,将来有人往 docroot 里丢了一个debug.php,它不会因为"文件存在"就自动变成可访问入口。这个习惯我在多个项目里坚持下来,确实挡掉过几次手滑。

5.2 前端联调:代理、预检请求与统一响应头

前后端分离的项目里,前端通常跑在localhost:5173这类端口上,后端接口在内置服务器的127.0.0.1:8000。这时候有两种联调姿势。

第一种是前端 dev server 反向代理,在 Vite 或 webpack 的配置里把/api前缀的请求转发到内置服务器。这是我最推荐的,因为浏览器看到的还是同源请求,完全没有跨域问题,也不需要在后端加任何 CORS 头,最省事。

第二种是后端加 CORS 头。如果只能走这条路,记得几件事:Access-Control-Allow-Origin不要图省事写*又同时带上Access-Control-Allow-Credentials: true,浏览器会直接拒绝;要带上Vary: Origin,否则中间的缓存层可能把给 A 站点的响应发给 B 站点;预检请求是OPTIONS方法,内置服务器不会特殊处理它,直接交给路由脚本,所以要在路由脚本最前面拦截:

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { header('Access-Control-Allow-Origin: http://localhost:5173'); header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, Authorization'); header('Access-Control-Max-Age: 86400'); header('Vary: Origin'); http_response_code(204); exit; }

把这段放在路由脚本的最顶部,在所有业务逻辑之前。原因是预检请求不携带业务数据,没有必要经过认证和路由分发,提前返回还能省掉一次完整的应用初始化。

5.3 用 composer script 或脚本文件封装启动命令

每次手敲那串带环境变量的启动命令太累,也容易记错。如果你的项目已经有 composer.json,直接加一个 script 最省事:

{ "scripts": { "dev": "php -S 127.0.0.1:8000 -t public router.php" } }

之后composer dev就能起服务。要注意 composer script 的工作目录是 composer.json 所在目录,也就是项目根目录,所以router.php-t public都用相对根目录的写法就行。

如果需要更复杂的启动逻辑——比如启动前检查.env是否存在、自动生成缓存目录、同时开一个队列消费进程——那就写一个 shell 脚本或者批处理:

#!/usr/bin/env bash set -e cd "$(dirname "$0")/.." [ -f .env ] || cp .env.example .env mkdir -p runtime/cache echo "服务已启动: http://127.0.0.1:8000" PHP_CLI_SERVER_WORKERS=4 php -S 127.0.0.1:8000 -t public router.php

脚本里第一行的cd很关键,它保证了无论从哪个目录调用这个脚本,工作目录都是项目根,路径问题一次性解决。

5.4 在响应头里留下路由痕迹

前面 3.4 提到过X-Route响应头,这里展开说说为什么我觉得它值回票价。开发阶段最耗时的往往不是写代码,而是"为什么这个请求没进我预期的那个方法"。传统做法是打日志或者下断点,但日志要翻文件、断点会打断前端并行请求(尤其在单线程模式下,断点一挂,整个页面卡住)。

加一个X-Route头之后,你只要打开浏览器 Network 面板,点开任意一个请求看 Response Headers,就能知道它命中了哪条规则、有没有命中任何规则。配合X-Route-Time记录路由匹配耗时,还能顺带发现某条正则写得特别慢。这三个头在开发环境加上,上线前用环境变量控制关掉,成本几乎为零,收益很实在。

if (getenv('APP_DEBUG')) { header('X-Route: ' . $name); header('X-Route-Time: ' . round((microtime(true) - $start) * 1000, 2) . 'ms'); }

5.5 什么时候必须放弃内置服务器

讲了这么多技巧,也得把边界说清楚,免得有人拿它去扛不该扛的活。以下这些场景,内置服务器不是"配置一下就能用",而是能力上根本没有:

  • 需要 HTTPS 本地调试证书,做微信登录回调、OAuth 回调这类必须 https 的场景;
  • 需要多虚拟主机,一个端口对应多个站点;
  • 需要做压力测试,看真实的 QPS 和内存表现;
  • 需要访问日志和错误日志分开落盘、按天切分;
  • 需要自定义 404、限流、IP 白名单这类服务器层面的控制。

遇到这些,老老实实装一套 Nginx + PHP 处理。但请注意:换掉服务器不等于换掉路由逻辑。你在 router.php 里写的那套分层判断(静态资源白名单 → 真实入口 → 应用分发),完全可以一比一翻译成 Nginx 的try_files加几条location,因为判断顺序是一致的。这也是我建议把路由规则用代码写在 router.php 里的原因之一——它比一堆正则更容易读懂,也更容易在换环境的时候对照着翻译过去。

最后分享两个我在实际使用中养成的小习惯。一是把 router.php 放到tools/目录而不是项目根目录,启动命令写成php -S 127.0.0.1:8000 -t public tools/router.php,这样项目根目录清爽,路由脚本也不会被误当作可访问文件;同时脚本内部所有路径都用__DIR__ . '/../public'这种形式拼绝对路径,杜绝工作目录带来的差异。二是每次新建项目,先在根目录跑一次php -S带着一个只做header('Content-Type: text/plain'); echo $_SERVER['REQUEST_URI'];的探针脚本,把路由规则验证一遍再加业务逻辑——这个动作只花五分钟,但能把后面几小时的环境排查时间省下来。踩过的坑告诉我,本地环境的问题九成出在路径判断上,先把路径捋顺,剩下的都好办。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询