PHP 8.6 弃用了一批名字:let、namespace、in/out,以及 finally 里的 return
把 CI 切到 PHP 8.6 后,测试还能跑完,但日志里会多出几行:
Deprecated: Calling a class "let" is deprecated
Deprecated: Returning from a finally block is deprecated
这些都不是 Error,进程退出码也没有变化。触发提示的是标识符命名和两处控制流写法。PHP 8.6 定于 2026-11-19 发布(RC5 2026-11-05),这批变更来自 RFC「Deprecations for PHP 8.6」(2025-10-12,Status: Pending Implementation),另一份整理见 Nuno Maduro 的 Everything New in PHP 8.6。
- RFC: https://wiki.php.net/rfc/deprecations_php_8_6
- 参考: https://nunomaduro.com/posts/everything-new-in-php-8-6
弃用不等于报错,移除在 PHP 9
RFC 的原话是:A deprecation is not an error. Your code runs, and you get an E_DEPRECATED notice that tells you what to change before PHP 9.
判断一个写法是否受影响,看三点就够了:
- 在
error_reporting=E_ALL下是否出现E_DEPRECATED; - 行为是否改变——这批里只有
finally里的return本来就是语义陷阱,其余都只是命名限制; - 移除版本是 PHP 9,8.6 只负责让提示出现在日志里。
所以处理节奏是:先让这些提示出现在测试日志和静态扫描结果里,再在 9 之前清理完;不要在 8.6 上线前做大规模改名,尤其是不要盲目跑批量替换(见下文 list())。
逐条:建议不要再使用这些名字
finally 里的 return
Deprecated: Returning from a finally block is deprecated
当 try 或 catch 因抛异常或返回而退出时,finally 里的 return 会悄悄地丢弃这个退出:异常被吞,返回值被覆盖,调用方拿到一个看起来很正常的返回值。
function getConfig(): array {
try {
return loadConfig(); // 文件缺失时抛异常
} finally {
return []; // 异常没了,调用方只拿到 []
}
}
RFC 对最常用的约 5000 个 Composer 包做了静态扫描:finally 里出现 return 的共 12 处,分布在 9 个包中。逐个人工核对后,9 处是有意为之或无害,3 处的 try 在真实路径上会抛异常,而 finally 的 return 会把异常吞掉。投票结果:39 yes / 3 no / 4 abstain。
let 作标识符
Deprecated: Calling a {class|trait|interface|enum|constant|function} "let" is deprecated
class_alias() 的第二个参数传 "let" 同样被弃用。
动机是把这个词正式预留给未来的关键字。let 曾作为 Block Scoping RFC 的一部分提出,该 RFC 未被接受;但块作用域最自然的关键字就是 let,现在预留之后,将来的块作用域 RFC 可以直接使用它而不造成破坏性变更。静态扫描命中 3 处。
in / out / inout 作标识符
这三个词的预留方向是引用与泛型。RFC 给出的两个理由:一是它们可能构成 PHP 处理引用(类型一致性与可变性)的新语法的基础;二是它们可能用作泛型类型的协变/逆变标记——这一观察来自「Bound-Erased Generic Types」RFC 的二次投票,那轮投票讨论的正是采用哪种 variance 标记语法。
扫描命中 33 处:in 25 处(涉及 Doctrine、Laminas、Laravel、Magento),out 8 处,inout 0 处。也就是说,实际受影响的主要是 in,而且集中在几个大生态包里。
namespace 作类常量名
namespace 目前是半保留字,因此允许作为类成员名:
class Foo { const NAMESPACE = ''; }
动机是给未来的语法留位置。预留之后,可以实现一个类似 ::class 的 ::namespace 伪常量,用来表示目录命名空间,把 "Order\\Domain\\Entities" 这种字符串 API 换成 \Order\Domain\Entities::namespace,IDE 支持更好。GitHub 搜索约 120 个匹配文件,RFC 的影响分析记为 21 处。
readonly 作函数名
readonly 最初允许作函数名,是因为 WordPress 有一个同名函数,当时靠在 lexer 里加了一段 hack 实现。这个 hack 现在被弃用。
_ 作常量名与编译期别名
弃用 "_" 作为常量名,以及作为编译期别名;同时弃用 "_" 作为函数名和 gettext() 的 _() 别名。
另外三条容易漏的
define()的第三个参数$case_insensitive:define('FOO', 1, true)这种写法被弃用。is_a()/is_subclass_of()在$allow_string为false时传字符串。__debugInfo()的返回类型写成?array,应写成array。
函数别名这一批的提示长这样:
Deprecated: Function is_integer() is deprecated since 8.6, use is_int() instead
Deprecated: Function is_double() is deprecated since 8.6, use is_float() instead
Deprecated: Function doubleval() is deprecated since 8.6, use floatval() instead
Deprecated: Function spl_object_hash() is deprecated since 8.6, consider using spl_object_id() instead
同一份 RFC 里还有 __construct() / __destruct() 中带值 return、构造器或析构器写成 generator、把对象传给 array_walk() 系列函数的数组参数、ReflectionProperty::setValue() 传错类型对象、SplFileObject 的 CSV 方法、mysqli_stmt_init()、mb_ereg*、php://filter 超过 16 个 filter、session handler 缺 create_sid()/validateId() 等,这些与命名保留无关,不在本文逐条展开。
list() 这次没有被弃用
list() 与短语法完全等价,不提供任何额外能力,只是为了向后兼容保留的长写法。提案给出的迁移方式是:
ast-grep run --pattern 'list($$$ARGS)' --rewrite '[$$$ARGS]' --lang php -U
扫描结果确实很大:5545 个文件里 12275 处。
但最终投票是 23 yes / 23 no / 1 abstain,未达到 2/3 门槛,所以 PHP 8.6 实际并未弃用 list()。这也是不要盲目跑批量替换的直接原因:工具能改,不代表当前版本要求你改;大批量的机械改写会带来无谓的 diff 和 review 成本。
哪些写法不受影响
- 名为
namespace的方法和静态属性不受影响,例如 Laravel 的Route::namespace()。 - 常量持有的对象可以写属性(
const CONFIG = new Config(); CONFIG->items['app.name'] = 'Acme';),但常量本身不能替换,enum case 的属性仍然不可写。 readonly属性可以就地写默认值(public readonly int $perPage = 15;),重复赋值仍然报Error: Cannot modify readonly property Settings::$perPage。
怎么查出来
先用 grep 粗筛,误报可以人工过:
# 声明位置用了保留字
grep -rnE --include='*.php' \
'(class|interface|trait|enum|function|const)[[:space:]]+(let|is|in|out|inout)\b' .
# namespace 作类常量
grep -rnE --include='*.php' 'const[[:space:]]+namespace\b' .
# readonly 作函数名
grep -rnE --include='*.php' 'function[[:space:]]+readonly\b' .
# define 第三参数
grep -rnE --include='*.php' "define\([^)]*,[^)]*,[[:space:]]*true" .
finally 里的 return 更适合用 ast-grep 定位到块再人工判断,因为这个模式是否真的会吞异常,取决于 try 里有没有会抛的路径:
ast-grep run --pattern 'finally { $$$BODY }' --lang php .
更可靠的做法是让运行时自己报告。php -l 只做语法检查,不执行代码,不会触发 E_DEPRECATED;要真正跑起来:
php -d error_reporting=E_ALL -d display_errors=1 vendor/bin/phpunit
PHPStan 能覆盖一部分静态命名问题,但对本轮这种「名字本身被弃用」的规则覆盖有限,不能只靠它。
最后可以直接编译 8.6 分支验证:
git clone --depth 1 --branch PHP-8.6 https://github.com/php/php-src.git
cd php-src && ./buildconf --force \
&& ./configure --disable-all --enable-cli --enable-mbstring --disable-mbregex \
--enable-tokenizer --enable-ctype --enable-filter \
&& make -j8
容易漏的两个点单独记一下:define() 的 $case_insensitive 第三参数在代码里常常只写两个参数,grep 时要按三个参数的位置找;__debugInfo() 的 ?array 返回类型则要翻实现 __debugInfo 的类,光看调用方是看不出来的。