PHP 8.6 只是个小版本,三十项弃用却已在为 PHP 9.0 铺路
部分函数应用、Time\Duration,以及一份实质上是为 PHP 9.0 做准备的弃用清单
本文基于 PHP 8.6 beta 3 撰写。特性集已经冻结;若正式版(GA)之前出现变动,文中会予以说明。
PHP 8.6 将于 2026 年 11 月 19 日发布,其变更规模比 8.5 更小。没有 JIT,没有属性钩子(property hooks),没有联合类型------没有任何会重塑类编写方式的改动。
因此,先给出坦率的结论:升级到 8.6 只需要一个下午。而在 8.6 上把代码改干净,正是避免 PHP 9.0 升级痛苦的方式。
有三件事值得关注。部分函数应用是一个确实优秀的语言特性,补上了去年管道运算符所缺失的那一半。Time\Duration 是一个小类,却悄悄修正了标准库延续二十年的设计失误。而大约三十项弃用,才是这个版本真正的主题------它们不是噪音,而是一张清晰可读的图,指明这门语言下一步的走向。
下面依次讨论这三部分,随后给出迁移方案。
头号特性:部分函数应用
这是真正会被广泛讨论的特性,它以 33 票赞成、0 票反对获得通过------这种全票通过的局面在语法类提案中几乎从未出现。
其思路是:调用一个函数,但把部分参数留空,得到的是一个等待其余参数的闭包。
php
<?php
$makeSlug = str_replace(' ', '-', ?);
$makeSlug('Hello World'); // 'Hello-World'
占位符有两种。? 表示该位置恰好接收一个参数,... 表示"其余全部参数,无论是什么"。
php
<?php
function stuff(int $i1, string $s2, float $f3, Point $p4, int $m5 = 0): string {}
// ... 保持签名尾部的完整性
$c = stuff(1, 'hi', ...);
// fn(float $f3, Point $p4, int $m5 = 0): string
// 绑定值与占位符可以自由混用
$c = stuff(1, ?, 3.5, ...);
// fn(string $s2, Point $p4, int $m5 = 0): string
具名占位符则是此前未曾预料到的部分。借助它,开发者可以重新排列所生成闭包的签名;当需要把回调传入一个参数顺序由 API 规定的接口时,这一点影响很大:
php
<?php
$c = stuff(s2: ?, i1: ?, p4: ?, f3: 3.5);
// fn(string $s2, int $i1, Point $p4): string
为什么现在值得关注
因为 PHP 8.5 提供了管道运算符,而没有部分函数应用的管道用起来会以一种非常具体的方式令人沮丧。几乎所有有用的转换都不止接收一个参数,于是最终只能把所有东西都包起来:
php
<?php
// PHP 8.5:管道里塞满了箭头函数
$result = $input
|> fn($s) => trim($s)
|> fn($s) => str_replace(' ', '-', $s)
|> fn($s) => mb_strtolower($s)
|> fn($s) => mb_substr($s, 0, 50);
php
<?php
// PHP 8.6:管道本身就是代码
$result = $input
|> trim(?)
|> str_replace(' ', '-', ?)
|> mb_strtolower(?)
|> mb_substr(?, 0, 50);
第二段代码正是这个版本值得关注的理由所在。只要愿意写 Closure::fromCallable() 和一堆适配器,PHP 中早就可以实现无点风格(point-free style);而现在,它就是编写管道的方式。
团队里必然有人会踩的坑
部分应用中的参数在创建该部分应用的时刻即完成求值,而不是在调用时才求值。箭头函数则会延迟求值。下面两行看起来等价,实际上并不等价:
php
<?php
$partial = speak(?, getArg()); // getArg() 现在执行,且只执行一次
$arrow = fn($who) => speak($who, getArg()); // getArg() 每次调用都会执行
如果 getArg() 读取的是请求、时钟或可变容器,那么这就是两个不同的程序。要么有意识地提前绑定,要么改用箭头函数。
限制
部分函数应用不能用于 new(应改用静态工厂),不能用于 __get/__set(它们并非以方法形式被调用,因此没有可供部分应用的对象),也不能用于职责本身就是读取调用作用域的函数------compact()、extract()、func_get_arg()、get_defined_vars()。改变该作用域正是部分函数应用的核心目的,因此这些函数从一开始就不在可行范围内。
有一条语法规则需要记住:具名参数之后出现位置参数 ? 是致命错误(fatal error),而不是警告。
另外值得了解的是,这与 8.1 引入的一等可调用对象(first-class callable)有何区别。foo(...) 生成的是对该函数本身的一个引用;而 PFA 构建的是一个把参数预先烘焙进去的新闭包。实际影响在于属性(attributes)不再随之传递:FCC 会复制全部属性,而部分应用只保留 #[\NoDiscard] 与 #[\SensitiveParameter]。如果对可调用对象做了任何与属性相关的反射操作,需要重新核对该假设。
Time\Duration:修正一个延续二十年的 API 设计瑕疵
关于标准库的一个小测验。sleep() 接收秒。usleep() 接收微秒。stream_set_timeout() 接收秒与微秒两个参数。curl_setopt() 的 CURLOPT_TIMEOUT 以秒为单位,CURLOPT_TIMEOUT_MS 以毫秒为单位。Redis 客户端接收浮点数。从来没有人能够凭记忆把所有这些都写对。
PHP 8.6 引入了 final readonly class Time\Duration(35 票赞成、1 票反对),让超时能够携带自己的单位:
php
<?php
use Time\Duration;
$halfSecond = Duration::fromMilliseconds(500);
$combined = Duration::fromSeconds(1)->add($halfSecond);
// 读起来就像指数退避的指数退避实现
$delay = $baseDelay->multiplyBy(2 ** $attempt);
工厂方法覆盖了预期中的全部单位:fromNanoseconds()、fromMicroseconds()、fromMilliseconds()、fromSeconds(int $s, int $ns = 0)、fromMinutes()、fromHours() 以及 fromIso8601DurationString()。实例提供 add()、sub()、multiplyBy(int)、divideBy(int)、negate()、absolute() 和静态的 compare(),此外还有只读的 $seconds、$nanoseconds 与 $negative 属性。
有两项设计决策值得指出,因为它们正是这个类之所以优秀而非冗余的原因。
它是秒表,不是日历。没有月,没有年,没有夏令时。这是刻意的:脱离起始日期,"一个月"的时长是没有意义的,那是 DateInterval 的职责。Duration 用于表达"等待这么久",而通过拒绝建模日历运算,它避免了歧义。
它是基础设施,不是语法糖。在 8.6 中,内核里第一个使用它的是 IO\Poll\Context::wait(?Time\Duration $timeout)。这只是一个单薄的开端,其价值不在于方法列表,而在于此后每一个接收超时的 API 都能在内核中接受一个明确、统一且不引入任何依赖的类型。预计框架与客户端库会在一两个版本之内,在现有的 int/float 参数之外同时接受 Duration。
有一件事需要在 RC 阶段核实:在最终构建中,究竟有哪些现有核心函数接受 Duration。这一点在开发过程中发生过变动,早期文章之间的说法并不一致。
没有人会直接调用的异步底层管道
PHP 8.6 提供了一个底层 I/O 多路复用 API------IO\Poll\Context、StreamPollHandle、Event::Read:
php
<?php
$context = new IO\Poll\Context();
$context->add(new StreamPollHandle($stream), [Event::Read], ['type' => 'server']);
$watchers = $context->wait(Duration::fromSeconds(1));
需要直说其受众范围:应用代码永远不会接触它。但它依然重要。ReactPHP、Amp、Swoole 以及 FrankenPHP 风格的各种运行时,目前都在 stream_select() 或各自的扩展之上重新实现事件轮询,边缘行为不同,平台支持也不同。核心中出现这样一个原语,正是"异步 PHP"从一组互不兼容的方言,变成运行时自身属性的一条路径。
这是五年的回报,而不是 8.6 的回报。值得记下,然后继续。
其余值得关注的改进
以下内容可快速扫读,因为它们都不需要单独成节:
clamp()------clamp(101, min: 0, max: 100)返回100。字符串同样适用:clamp("a", "x", "z")返回"x"。每个人都写过这个辅助函数,现在可以删掉了。- 只读属性可以有默认值 ------ 早该如此,在实现接口属性钩子时尤其重要。
#[\Override]支持类常量 ------ 8.3 引入的这个属性仍在扩张:8.5 给了属性,8.6 给了常量。enum SortDirection { Ascending; Descending; }------ 内置的替代方案,取代每个项目都会重新声明的 SORT_ASC 风格整数常量。- 可调试的枚举 ------ 枚举可以实现
__debugInfo()。 - 参数上的文档注释 ------
function store(/** @param Book[] */ array $books): void {}。微小的语法改动,对数组形状的静态分析而言却是实实在在的收益。 - 更好的错误信息 ------ 错误现在会显示函数参数,
json_decode()会指出解析失败的位置。纯粹的调试体验改善,也正是凌晨两点时会感激的那类改动。 - 反射 ------ 属性上的
isReadable()与isWriteable(),且作用域感知。 - 闭包优化 ------ 可视为免费的性能提升。此项尚无基准测试验证,因此不引用具体数字。
pack()/unpack()的字节序修饰符 ------<与>现在可用于浮点数和有符号整数。较为小众,但如果处理二进制协议,它能省掉大量手写的字节交换。
同样落地的还有:grapheme_strrev()、mysqli_quote_string()、Locale::getDisplayKeyword()、IntlNumberRangeFormatter、流的 TLS 会话恢复与 0-RTT 早期数据、用于惰性行获取的 Pdo\Pgsql::ATTR_CHUNK_SIZE、gmp_powm_sec() 与 gmp_prevprime()、支持远程流的 finfo_file(),以及延续 8.5 的 RFC 3986 / WHATWG URL 工作的 URI 构建类。
未能通过的提案
被拒绝的提案和通过的提案一样能说明问题。
- 管道赋值运算符(
|>=)------ 被否决,距管道运算符本身获得通过恰好一年。 let构造 / 块级作用域 ------ 被否决。但let仍被保留为关键字,这一点后文会再提到。- 绑定擦除的泛型类型(bound-erased generic types)------ 被否决。泛型仍是 PHP 长期悬而未决的问题。
__exists(),用于区分"缺失"与"被设为 null"的魔术方法 ------ 被否决。array_only_keys()/array_except_keys()、array_path_get()/array_path_exists()、前缀/后缀字符串函数 ------ 全部被否决。- 在构造函数中重新赋值提升的只读属性 ------ 被否决。
- 弃用
list()------ 23 票对 23 票,未能达到三分之二的门槛,list()得以保留。
看看这些提案的形态:PHP 内核开发者(internals)几乎否决了所有数组便利函数,却对语言底层管道投了赞成票。如果需要 array_except_keys(),那是 Laravel 的职责,而不是引擎的职责。这是一个自洽的立场,即便它有时令人不快。
还有两件事没能赶上这趟车,仍在讨论之中:函数自动加载(function autoloading,标记为 5)与 BackedEnum::values()。ReflectionAttribute::getCurrent() 在 beta 阶段仍在投票,依赖它之前应确认最终状态。
迁移:真正要做的功课
先说好消息。这里没有 PHP 8.0 那样的墙。没有引擎重写,没有大面积类型错误。对于一个运行在 8.4 或 8.5 上、测试完备的应用而言,这只是一次版本号提升和一次绿色的测试通过。
工作量集中在弃用项上。建议的处理顺序如下。
1. 升级版本,并让弃用提示可见
bash
php -d error_reporting=E_ALL -d display_errors=1 vendor/bin/phpunit
如果 CI 屏蔽了 E_DEPRECATED------很多 CI 确实如此------那么测试会通过、版本会发布,而所有这些弃用项都会在 PHP 9.0 中作为硬错误被继承下来。把它们打开,然后数一数弃用警告有多少行。这个数字就是实际的升级工作量估算。
2. 完成机械式重命名
零风险,纯查找替换:
| 已弃用 | 替代方案 |
|---|---|
is_double() |
is_float() |
is_integer()、is_long() |
is_int() |
doubleval() |
floatval() |
spl_object_hash() |
spl_object_id() |
spl_classes() |
反射,或显式映射表 |
strcoll()、SORT_LOCALE_STRING |
intl 的 Collator |
metaphone() |
soundex(),或用户态的语音库 |
mysqli_stmt_init()、mysqli_get_charset() |
直接使用预处理语句 / 连接属性 |
define() 的 $case_insensitive |
只定义一次常量,并保证正确 |
3. 停止向数组位置传入对象
这是最可能真正出现在代码库中的一类。对象作为以下函数的输入已被弃用:array_walk()、array_walk_recursive()、deflate_init()、inflate_init()、zlib 与 bzip2 流过滤器参数、mb_convert_variables() 以及 http_build_query()。修复方式几乎总是在调用处加上 get_object_vars($obj)。
array_walk() 的投票结果是 41 票赞成、3 票反对,这足以说明这项清理有多不受争议。
顺便处理:以字符串作为第一个参数的 is_a() 与 is_subclass_of() 已被弃用;ArrayIterator 中那些继承而来却毫无意义的方法、SplFileObject 的 CSV 方法,以及不完整的 session_set_save_handler() 实现,同样如此。
4. 释放标识符
readonly 作为函数名(39 票对 1 票)、is 与 let 作为标识符、namespace 作为类常量名,以及 _ 作为常量,均已被弃用。引擎正在保留词汇------尤其是 is,它被留作未来模式匹配之用。
这些现在修复成本很低,将来却会成为硬错误。注意 in、out、inout 以及 _() 这个 gettext 别名曾被提议作同样处理但遭否决,因此不要动它们。
5. mb_ereg* 已进入倒计时
Oniguruma------mbstring 的 mb_ereg 系列背后的正则引擎------已于 2025 年 4 月 24 日停止上游维护。PHP 的回应是:8.6 中弃用,PHP 9.0 中移除。
如果正在使用 mb_ereg()、mb_eregi()、mb_ereg_replace()、mb_regex_encoding() 及其同族函数,这就是本版本中唯一一个真正的移植项目。应转向 PCRE,即使用带 u 修饰符的 preg_*。需要为它安排足够的预算------这是语义迁移,而不是重命名;两个引擎在足够多的细节上存在分歧,因此每迁移一个模式都应配套测试。Drupal 已经为此开了一个 issue,这在一定程度上说明了它的普遍程度。
6. 阅读那些静默的行为变更
这些变更会通过测试套件,然后在生产环境中出问题。
会话 INI 默认值出于安全考虑发生变化:session.use_strict_mode=1、session.cookie_httponly=1、session.cookie_samesite="Lax"。这是好改动,但它依然会毁掉某些人的部署------跨站 POST 流程以及任何读取会话 cookie 的 JavaScript 都会停止工作。请在 php.ini 中显式固定这三项,使升级过程无法在背后改动它们,然后再有意识地调整。
其余简要列出:
- 从
__construct()或__destruct()返回值已被弃用。从finally块中返回同样如此(39 票对 3 票,是整个弃用 RFC 中票数最悬殊的一项)。 trim()、ltrim()、rtrim()与chop()现在默认剥离换页符(\f)。如果解析定宽文本,这是静默的输出变化。- 一批过去只发警告的函数现在会抛异常:
array_filter()的$mode、GMP 的移位与幂运算、pcntl_alarm()/pcntl_exec()、Phar::mungServer()、curl 的CURLOPT_READFUNCTION返回值、不可字符串化的 intl 时区、SimpleXMLElement与会话 cookie 设置中的 NUL 字节。 preg_grep()现在出错时返回false,而不是部分数组。检查一下对该返回值的处理方式。- DOM 的只读属性使用
public private(set)------ 写入它们会报错,而不是静默失败。 - 在
__isset()已使属性实体化之后,??与empty()不再触发__get()。
7. 让工具来完成
Rector 的 PHP_86 规则集、最高级别的 PHPStan 或 Psalm,以及用 composer why 检查任何仍在调用已弃用 API 的依赖。现实中,上述大部分工作是一次 Rector 运行加上对 diff 的仔细阅读------真正的思考集中在第 5 步和第 6 步。
估算:一个运行在 8.4+ 上、测试完备的 Laravel 或 Symfony 应用,需要半天到一天,其中大部分时间是在等待依赖更新,而不是修改自己的代码。而一个带有 mb_ereg、缺少测试的历史遗留代码库,则是完全不同的话题。
总体评价
PHP 8.6 是一个小版本,也是一个声音很大的版本。
新增内容不多但选择得当:一个出色的语言特性,补完了 8.5 开始的工作;两个设计良好的类;更好的错误信息;以及免费的性能提升。这里没有任何东西强制重写,也没有任何东西属于设计失误。
弃用项才是真正的内容,而作为路线图来读,它们异常清晰。内部开发者正在为模式匹配保留关键字,剥离一个失去维护的正则引擎,终结"对象当数组用"的时代,把警告变成异常,并收紧安全默认值。这是一门语言在被有意维护,而不是被机会主义地扩张;对于现在就读懂这些信号的人而言,PHP 9.0 会是一次容易得多的升级。
因此,本周就做那件枯燥的事:用 error_reporting=E_ALL 针对 beta 跑一遍测试套件,然后数一数弃用提示的行数。这个数字即可说明这次升级是一个下午还是一场冲刺------无论哪种结果,在九月知道总比等到明年十一月要好。